Expose website tags and badges via business settings and business-info.
Stores custom meta tags and eNamad/Samandehi footer HTML in settings so storefronts can render them from the public API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
cffc828328
commit
63ae9b22b9
@@ -331,6 +331,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
||||
**Open Graph (recommended)**
|
||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||
|
||||
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||||
|
||||
Business admins configure this under **Website → Tags & Badges**.
|
||||
|
||||
1. `GET /tenants/{domain}/website/business-info`
|
||||
2. For each item in `metaTags` (array of `{ name, content }`), emit once in the root layout `<head>`:
|
||||
|
||||
```html
|
||||
<meta name="{name}" content="{content}">
|
||||
```
|
||||
|
||||
In Next.js App Router, map them into `generateMetadata()` → `other: { [name]: content }`. Verification metas (eNamad, Google, etc.) belong here as custom name/value rows.
|
||||
|
||||
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||||
|
||||
- When arrays are empty, omit tags / footer badges.
|
||||
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||||
|
||||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||
|
||||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||
@@ -398,6 +416,7 @@ Website rules:
|
||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
|
||||
@@ -567,6 +567,36 @@
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
||||
},
|
||||
"metaTags": {
|
||||
"type": "array",
|
||||
"description": "Enabled custom meta tags for the site <head> (name/content). Configured under Website → Tags & Badges.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"content": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "content"]
|
||||
}
|
||||
},
|
||||
"trustBadges": {
|
||||
"type": "array",
|
||||
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["enamad", "samandehi"]
|
||||
},
|
||||
"embedHtml": {
|
||||
"type": "string",
|
||||
"description": "Trusted HTML snippet (scripts stripped server-side)."
|
||||
}
|
||||
},
|
||||
"required": ["kind", "embedHtml"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
toPrismaJson,
|
||||
} from './business-settings.util';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||
|
||||
@@ -217,6 +218,31 @@ export class BusinessSettingsService {
|
||||
: current.website.schema.sameAs,
|
||||
})
|
||||
: current.website.schema,
|
||||
tagsAndBadges:
|
||||
dto.website.tagsAndBadges !== undefined
|
||||
? normalizeWebsiteTagsAndBadges({
|
||||
...current.website.tagsAndBadges,
|
||||
...dto.website.tagsAndBadges,
|
||||
metaTags:
|
||||
dto.website.tagsAndBadges.metaTags !== undefined
|
||||
? dto.website.tagsAndBadges.metaTags
|
||||
: current.website.tagsAndBadges.metaTags,
|
||||
enamad:
|
||||
dto.website.tagsAndBadges.enamad !== undefined
|
||||
? {
|
||||
...current.website.tagsAndBadges.enamad,
|
||||
...dto.website.tagsAndBadges.enamad,
|
||||
}
|
||||
: current.website.tagsAndBadges.enamad,
|
||||
samandehi:
|
||||
dto.website.tagsAndBadges.samandehi !== undefined
|
||||
? {
|
||||
...current.website.tagsAndBadges.samandehi,
|
||||
...dto.website.tagsAndBadges.samandehi,
|
||||
}
|
||||
: current.website.tagsAndBadges.samandehi,
|
||||
})
|
||||
: current.website.tagsAndBadges,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,37 @@ export type WebsiteSettings = {
|
||||
sitemapConfig?: WebsiteSitemapConfig | null;
|
||||
/** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
|
||||
schema: WebsiteSchemaSettings;
|
||||
/** Custom <meta> tags + eNamad / Samandehi trust badges. */
|
||||
tagsAndBadges: WebsiteTagsAndBadgesSettings;
|
||||
};
|
||||
|
||||
/** Custom head meta tag (name + content). Present = published. */
|
||||
export type WebsiteMetaTag = {
|
||||
id: string;
|
||||
name: string;
|
||||
content: string;
|
||||
};
|
||||
|
||||
/** Fixed trust integrations (eNamad / Samandehi) — footer HTML only. */
|
||||
export type WebsiteTrustBadgeSettings = {
|
||||
/** Footer widget HTML; empty = not shown. */
|
||||
embedHtml: string;
|
||||
};
|
||||
|
||||
export type WebsiteTagsAndBadgesSettings = {
|
||||
metaTags: WebsiteMetaTag[];
|
||||
enamad: WebsiteTrustBadgeSettings;
|
||||
samandehi: WebsiteTrustBadgeSettings;
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBSITE_TRUST_BADGE: WebsiteTrustBadgeSettings = {
|
||||
embedHtml: '',
|
||||
};
|
||||
|
||||
export const DEFAULT_WEBSITE_TAGS_AND_BADGES: WebsiteTagsAndBadgesSettings = {
|
||||
metaTags: [],
|
||||
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
};
|
||||
|
||||
/** Schema.org @type for the site-wide entity JSON-LD. */
|
||||
@@ -381,6 +412,11 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
website: {
|
||||
specialProductsSource: 'store_item',
|
||||
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
||||
tagsAndBadges: {
|
||||
metaTags: [],
|
||||
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
},
|
||||
},
|
||||
sms: {
|
||||
senderNumber: null,
|
||||
|
||||
@@ -34,12 +34,14 @@ import {
|
||||
WebsiteSitemapConfig,
|
||||
ZarinpalGatewaySettings,
|
||||
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||
} from './business-settings.types';
|
||||
import {
|
||||
defaultOrderStepColor,
|
||||
normalizeOrderStepColor,
|
||||
} from './order-step-colors';
|
||||
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||
import { normalizeWebsiteTagsAndBadges } from './website-tags-badges.util';
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
@@ -505,6 +507,11 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
schema: normalizeWebsiteSchemaSettings(
|
||||
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||
),
|
||||
tagsAndBadges: normalizeWebsiteTagsAndBadges(
|
||||
isRecord(website)
|
||||
? website.tagsAndBadges
|
||||
: DEFAULT_WEBSITE_TAGS_AND_BADGES,
|
||||
),
|
||||
},
|
||||
sms: {
|
||||
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||
@@ -571,6 +578,10 @@ export function mergeBusinessSettings(
|
||||
patch.website?.schema !== undefined
|
||||
? patch.website.schema
|
||||
: current.website.schema,
|
||||
tagsAndBadges:
|
||||
patch.website?.tagsAndBadges !== undefined
|
||||
? patch.website.tagsAndBadges
|
||||
: current.website.tagsAndBadges,
|
||||
},
|
||||
sms: {
|
||||
senderNumber:
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateIf,
|
||||
@@ -262,6 +264,48 @@ class WebsiteSchemaSettingsDto {
|
||||
sameAs?: string[];
|
||||
}
|
||||
|
||||
class WebsiteMetaTagDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-zA-Z0-9_.:-]{1,80}$/)
|
||||
name!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(2000)
|
||||
content!: string;
|
||||
}
|
||||
|
||||
class WebsiteTrustBadgeDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(10000)
|
||||
embedHtml?: string;
|
||||
}
|
||||
|
||||
class WebsiteTagsAndBadgesDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(40)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMetaTagDto)
|
||||
metaTags?: WebsiteMetaTagDto[];
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteTrustBadgeDto)
|
||||
enamad?: WebsiteTrustBadgeDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteTrustBadgeDto)
|
||||
samandehi?: WebsiteTrustBadgeDto;
|
||||
}
|
||||
|
||||
class WebsiteSettingsDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@@ -272,6 +316,11 @@ class WebsiteSettingsDto {
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteSchemaSettingsDto)
|
||||
schema?: WebsiteSchemaSettingsDto;
|
||||
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteTagsAndBadgesDto)
|
||||
tagsAndBadges?: WebsiteTagsAndBadgesDto;
|
||||
}
|
||||
|
||||
export class UpdateBusinessSettingsDto {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { randomUUID } from 'crypto';
|
||||
import {
|
||||
DEFAULT_WEBSITE_TRUST_BADGE,
|
||||
type WebsiteMetaTag,
|
||||
type WebsiteTagsAndBadgesSettings,
|
||||
type WebsiteTrustBadgeSettings,
|
||||
} from './business-settings.types';
|
||||
|
||||
const MAX_META_TAGS = 40;
|
||||
const MAX_NAME_LEN = 80;
|
||||
const MAX_CONTENT_LEN = 2000;
|
||||
const MAX_EMBED_LEN = 10000;
|
||||
const META_NAME_RE = /^[a-zA-Z0-9_.:-]{1,80}$/;
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown, maxLen: number): string {
|
||||
if (typeof value !== 'string') return '';
|
||||
return value.trim().slice(0, maxLen);
|
||||
}
|
||||
|
||||
/** Strip scripts / handlers from pasted trust-seal HTML. */
|
||||
export function sanitizeTrustEmbedHtml(raw: string): string {
|
||||
let html = raw.slice(0, MAX_EMBED_LEN);
|
||||
html = html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, '');
|
||||
html = html.replace(/<script\b[^>]*\/?>/gi, '');
|
||||
html = html.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
|
||||
html = html.replace(/javascript\s*:/gi, '');
|
||||
return html.trim();
|
||||
}
|
||||
|
||||
function normalizeTrustBadge(raw: unknown): WebsiteTrustBadgeSettings {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
return {
|
||||
embedHtml: sanitizeTrustEmbedHtml(
|
||||
typeof source.embedHtml === 'string'
|
||||
? source.embedHtml
|
||||
: DEFAULT_WEBSITE_TRUST_BADGE.embedHtml,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMetaTag(raw: unknown): WebsiteMetaTag | null {
|
||||
if (!isRecord(raw)) return null;
|
||||
const name = readString(raw.name, MAX_NAME_LEN);
|
||||
const content = readString(raw.content, MAX_CONTENT_LEN);
|
||||
if (!name || !META_NAME_RE.test(name) || !content) return null;
|
||||
// Legacy rows with enabled:false are ignored (treated as deleted).
|
||||
if (raw.enabled === false) return null;
|
||||
const id =
|
||||
typeof raw.id === 'string' && raw.id.trim().length > 0
|
||||
? raw.id.trim().slice(0, 64)
|
||||
: randomUUID();
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeWebsiteTagsAndBadges(
|
||||
raw: unknown,
|
||||
): WebsiteTagsAndBadgesSettings {
|
||||
const source = isRecord(raw) ? raw : {};
|
||||
const metaTags: WebsiteMetaTag[] = [];
|
||||
if (Array.isArray(source.metaTags)) {
|
||||
for (const item of source.metaTags) {
|
||||
const tag = normalizeMetaTag(item);
|
||||
if (!tag) continue;
|
||||
metaTags.push(tag);
|
||||
if (metaTags.length >= MAX_META_TAGS) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
metaTags,
|
||||
enamad: normalizeTrustBadge(source.enamad),
|
||||
samandehi: normalizeTrustBadge(source.samandehi),
|
||||
};
|
||||
}
|
||||
|
||||
export type PublicWebsiteMetaTag = { name: string; content: string };
|
||||
|
||||
export type PublicWebsiteTrustBadge = {
|
||||
kind: 'enamad' | 'samandehi';
|
||||
embedHtml: string;
|
||||
};
|
||||
|
||||
/** Custom meta tags present in settings → emit in storefront <head>. */
|
||||
export function toPublicWebsiteMetaTags(
|
||||
settings: WebsiteTagsAndBadgesSettings,
|
||||
): PublicWebsiteMetaTag[] {
|
||||
const out: PublicWebsiteMetaTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const tag of settings.metaTags) {
|
||||
if (!tag.name || !tag.content) continue;
|
||||
const key = tag.name.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
out.push({ name: tag.name, content: tag.content });
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Trust badges with non-empty HTML → render in the footer. */
|
||||
export function toPublicWebsiteTrustBadges(
|
||||
settings: WebsiteTagsAndBadgesSettings,
|
||||
): PublicWebsiteTrustBadge[] {
|
||||
const out: PublicWebsiteTrustBadge[] = [];
|
||||
for (const kind of ['enamad', 'samandehi'] as const) {
|
||||
const embedHtml = settings[kind].embedHtml;
|
||||
if (!embedHtml) continue;
|
||||
out.push({ kind, embedHtml });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function emptyWebsiteTagsAndBadges(): WebsiteTagsAndBadgesSettings {
|
||||
return {
|
||||
metaTags: [],
|
||||
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
|
||||
};
|
||||
}
|
||||
@@ -360,6 +360,24 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
||||
**Open Graph (recommended)**
|
||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||
|
||||
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||||
|
||||
Business admins configure this under **Website → Tags & Badges**.
|
||||
|
||||
1. `GET /tenants/{domain}/website/business-info`
|
||||
2. For each item in `metaTags` (array of `{ name, content }`), emit once in the root layout `<head>`:
|
||||
|
||||
```html
|
||||
<meta name="{name}" content="{content}">
|
||||
```
|
||||
|
||||
In Next.js App Router, map them into `generateMetadata()` → `other: { [name]: content }`. Verification metas (eNamad, Google, etc.) belong here as custom name/value rows.
|
||||
|
||||
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||||
|
||||
- When arrays are empty, omit tags / footer badges.
|
||||
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||||
|
||||
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||
|
||||
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||
@@ -427,6 +445,7 @@ Website rules:
|
||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
|
||||
@@ -571,6 +571,36 @@
|
||||
}
|
||||
},
|
||||
"required": ["enabled", "type", "sameAs", "jsonLd"]
|
||||
},
|
||||
"metaTags": {
|
||||
"type": "array",
|
||||
"description": "Enabled custom meta tags for the site <head> (name/content). Configured under Website → Tags & Badges.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string" },
|
||||
"content": { "type": "string" }
|
||||
},
|
||||
"required": ["name", "content"]
|
||||
}
|
||||
},
|
||||
"trustBadges": {
|
||||
"type": "array",
|
||||
"description": "Enabled eNamad / Samandehi footer widgets (sanitized HTML). Empty when none are shown.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["enamad", "samandehi"]
|
||||
},
|
||||
"embedHtml": {
|
||||
"type": "string",
|
||||
"description": "Trusted HTML snippet (scripts stripped server-side)."
|
||||
}
|
||||
},
|
||||
"required": ["kind", "embedHtml"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,10 @@ import {
|
||||
} from '../business-profile/business-profile.util';
|
||||
import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
|
||||
import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
|
||||
import {
|
||||
toPublicWebsiteMetaTags,
|
||||
toPublicWebsiteTrustBadges,
|
||||
} from '../business-settings/website-tags-badges.util';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { TenantService } from '../tenant/tenant.service';
|
||||
|
||||
@@ -90,6 +94,8 @@ export class WebsiteBusinessInfoService {
|
||||
sameAs: schemaSettings.sameAs,
|
||||
jsonLd,
|
||||
},
|
||||
metaTags: toPublicWebsiteMetaTags(settings.website.tagsAndBadges),
|
||||
trustBadges: toPublicWebsiteTrustBadges(settings.website.tagsAndBadges),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user