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:
Alireza Hassani
2026-10-03 18:07:13 +03:30
co-authored by Cursor
parent cffc828328
commit 63ae9b22b9
10 changed files with 354 additions and 0 deletions
+19
View File
@@ -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**.
+30
View File
@@ -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 },
};
}
+19
View File
@@ -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**.
+30
View File
@@ -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),
};
}
}