From 63ae9b22b981fe286a825bb5de4cc1e72e176ed8 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 3 Oct 2026 18:07:13 +0330 Subject: [PATCH] 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 --- docs/website-api/AI_PROMPT.md | 19 +++ docs/website-api/openapi.json | 30 ++++ .../business-settings.service.ts | 26 ++++ .../business-settings.types.ts | 36 +++++ .../business-settings.util.ts | 11 ++ .../dto/update-business-settings.dto.ts | 49 +++++++ .../website-tags-badges.util.ts | 128 ++++++++++++++++++ src/website-docs/static/AI_PROMPT.md | 19 +++ src/website-docs/static/openapi.json | 30 ++++ src/website/website-business-info.service.ts | 6 + 10 files changed, 354 insertions(+) create mode 100644 src/business-settings/website-tags-badges.util.ts diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index c0581d8..aced2b2 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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 ``: + +```html + +``` + +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**. diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index 78b1746..78687eb 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -567,6 +567,36 @@ } }, "required": ["enabled", "type", "sameAs", "jsonLd"] + }, + "metaTags": { + "type": "array", + "description": "Enabled custom meta tags for the site (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"] + } } } } diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index ddeba6e..2eb8a72 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -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, }; } diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 4ad8553..5a677ec 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -127,6 +127,37 @@ export type WebsiteSettings = { sitemapConfig?: WebsiteSitemapConfig | null; /** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */ schema: WebsiteSchemaSettings; + /** Custom 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, diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index e83c1dc..2927622 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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 { 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: diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 65fdcc2..1b74506 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -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 { diff --git a/src/business-settings/website-tags-badges.util.ts b/src/business-settings/website-tags-badges.util.ts new file mode 100644 index 0000000..9561768 --- /dev/null +++ b/src/business-settings/website-tags-badges.util.ts @@ -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 { + 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(/]*>[\s\S]*?<\/script>/gi, ''); + html = html.replace(/]*\/?>/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 . */ +export function toPublicWebsiteMetaTags( + settings: WebsiteTagsAndBadgesSettings, +): PublicWebsiteMetaTag[] { + const out: PublicWebsiteMetaTag[] = []; + const seen = new Set(); + + 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 }, + }; +} diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 164ddfb..23a6ad4 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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 ``: + +```html + +``` + +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**. diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index 81cf498..6de13d7 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -571,6 +571,36 @@ } }, "required": ["enabled", "type", "sameAs", "jsonLd"] + }, + "metaTags": { + "type": "array", + "description": "Enabled custom meta tags for the site (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"] + } } } } diff --git a/src/website/website-business-info.service.ts b/src/website/website-business-info.service.ts index 58e43d1..1f05766 100644 --- a/src/website/website-business-info.service.ts +++ b/src/website/website-business-info.service.ts @@ -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), }; } }