diff --git a/database/migrations/088_categories_metadata.sql b/database/migrations/088_categories_metadata.sql
new file mode 100644
index 0000000..ad6458e
--- /dev/null
+++ b/database/migrations/088_categories_metadata.sql
@@ -0,0 +1,6 @@
+-- Optional SEO overrides for category pages (same keys as content metadata JSON).
+ALTER TABLE categories
+ ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
+
+COMMENT ON COLUMN categories.metadata IS
+ 'JSON bag for category SEO overrides: seoMetaTitle, seoMetaDescription';
diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md
index bd6ffa2..fdb09d8 100644
--- a/docs/website-api/AI_PROMPT.md
+++ b/docs/website-api/AI_PROMPT.md
@@ -319,11 +319,73 @@ 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 Schema.org JSON-LD (Organization / LocalBusiness)
+
+Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
+
+1. `GET /tenants/{domain}/website/business-info`
+2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
+
+```html
+
+```
+
+In Next.js App Router, put it in the root layout via `` when `jsonLd` is non-null.
+
+- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
+- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
+- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
+- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
+
+### Product + blog Schema.org JSON-LD (auto)
+
+When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
+
+| Page | Endpoint | `@type` |
+|------|----------|---------|
+| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
+| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
+
+On the product/blog page:
+
+```tsx
+{product.schema?.jsonLd ? (
+
+) : null}
+```
+
+- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
+- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
+- Do not invent Product/Article fields locally when `jsonLd` is present.
+
+### Per-page SEO meta overrides (optional)
+
+CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
+
+Public detail payloads include:
+
+- `seoMetaTitle` (`string | null`)
+- `seoMetaDescription` (`string | null`)
+
+Website rules:
+
+1. Prefer `seoMetaTitle` for `
` when non-null; else use the normal title/`nameFa`.
+2. Prefer `seoMetaDescription` for `` when non-null; else summary/abstract/about.
+3. Product/blog `schema.jsonLd` already prefers these overrides when set.
+4. Empty/null means “no override” — never show placeholder text as meta.
+
**Checklist before shipping a page**
-1. Unique `` and meta description
+1. Unique `` and meta description (use `seoMeta*` when present)
2. One clear H1 + sensible H2 sections
3. All content images have alt text
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
If OpenAPI and this brief conflict, **OpenAPI wins**.
diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json
index 4450e7b..dd732cc 100644
--- a/docs/website-api/openapi.json
+++ b/docs/website-api/openapi.json
@@ -195,6 +195,18 @@
"store_item"
],
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
+ },
+ "schema": {
+ "type": "object",
+ "description": "Site-wide Schema.org flags. Full jsonLd is on GET /tenants/{domain}/website/business-info.",
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "type": {
+ "type": "string",
+ "enum": ["Organization", "LocalBusiness"]
+ }
+ },
+ "required": ["enabled", "type"]
}
}
}
@@ -530,7 +542,32 @@
}
},
"socialMedia": { "type": "object" },
- "addresses": { "type": "array", "items": { "type": "object" } }
+ "addresses": { "type": "array", "items": { "type": "object" } },
+ "schema": {
+ "type": "object",
+ "description": "Site-wide Schema.org settings + ready-to-emit jsonLd (Phase A).",
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "type": {
+ "type": "string",
+ "enum": ["Organization", "LocalBusiness"]
+ },
+ "name": { "type": "string", "nullable": true },
+ "nameFa": { "type": "string", "nullable": true },
+ "description": { "type": "string", "nullable": true },
+ "priceRange": { "type": "string", "nullable": true },
+ "sameAs": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "jsonLd": {
+ "type": "object",
+ "nullable": true,
+ "description": "Null when schema.enabled is false. Otherwise emit as application/ld+json on every public page (or at least the layout)."
+ }
+ },
+ "required": ["enabled", "type", "sameAs", "jsonLd"]
+ }
}
}
}
@@ -1001,7 +1038,7 @@
],
"responses": {
"200": {
- "description": "{ product, relatedProducts } — same shape as product-by-slug"
+ "description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema."
}
}
}
@@ -1080,7 +1117,7 @@
],
"responses": {
"200": {
- "description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
+ "description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema. relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
}
}
}
@@ -1622,7 +1659,7 @@
],
"responses": {
"200": {
- "description": "{ blog }"
+ "description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1648,7 +1685,7 @@
],
"responses": {
"200": {
- "description": "{ blog }"
+ "description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index dfe3911..55a5257 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -427,6 +427,7 @@ model Category {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
nameFa String? @map("name_fa") @db.VarChar(255)
oldId BigInt? @map("old_id")
+ metadata Json @default("{}")
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
children Category[] @relation("CategoryTree")
diff --git a/src/blogs/blogs.service.ts b/src/blogs/blogs.service.ts
index cdbe52b..e7983a9 100644
--- a/src/blogs/blogs.service.ts
+++ b/src/blogs/blogs.service.ts
@@ -12,7 +12,16 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
+import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
+import { buildBlogPostingJsonLd } from '../business-settings/website-entity-schema.util';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
+import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
+import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -138,7 +147,10 @@ export class BlogsService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
include: blogInclude,
});
@@ -218,10 +230,13 @@ export class BlogsService {
nextContent.html = dto.mainTextHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -374,10 +389,7 @@ export class BlogsService {
}
return {
- blog: await this.serializeBlog(blog, {
- includeComments: true,
- approvedCommentsOnly: true,
- }),
+ blog: await this.serializePublicBlog(host, business, blog),
};
}
@@ -405,10 +417,71 @@ export class BlogsService {
}
return {
- blog: await this.serializeBlog(blog, {
- includeComments: true,
- approvedCommentsOnly: true,
- }),
+ blog: await this.serializePublicBlog(host, business, blog),
+ };
+ }
+
+ private async serializePublicBlog(
+ host: string,
+ business: { id: bigint; settings: Prisma.JsonValue; name: string; nameFa: string | null },
+ blog: BlogWithRelations,
+ ) {
+ const serialized = await this.serializeBlog(blog, {
+ includeComments: true,
+ approvedCommentsOnly: true,
+ });
+
+ const settings = normalizeBusinessSettings(business.settings);
+ const normalizedHost = host
+ .toLowerCase()
+ .trim()
+ .replace(/^(customer|business)\./, '');
+
+ const domain = await this.prisma.domain.findFirst({
+ where: { businessId: business.id, isPrimary: true },
+ select: { host: true },
+ });
+ const primaryHost = domain?.host ?? normalizedHost;
+ const sitemap = resolveSitemapConfig(
+ settings.website.sitemapConfig ?? null,
+ primaryHost,
+ );
+
+ const media = await this.prisma.business.findUnique({
+ where: { id: business.id },
+ select: { logoMedia: { select: { publicUrl: true } } },
+ });
+
+ const authorName = serialized.author
+ ? [serialized.author.firstName, serialized.author.lastName]
+ .filter(Boolean)
+ .join(' ')
+ .trim()
+ : '';
+
+ const jsonLd = settings.website.schema.enabled
+ ? buildBlogPostingJsonLd({
+ host: primaryHost,
+ id: serialized.id,
+ title: serialized.seoMetaTitle || serialized.title,
+ slug: serialized.slug,
+ abstract: serialized.seoMetaDescription || serialized.abstract,
+ mainTextHtml: serialized.seoMetaDescription
+ ? null
+ : serialized.mainTextHtml,
+ titleImageUrl: serialized.titleImageUrl,
+ publishedAt: serialized.publishedAt,
+ updatedAt: serialized.updatedAt,
+ authorName: authorName || null,
+ publisherName: business.nameFa || business.name,
+ publisherLogoUrl: media?.logoMedia?.publicUrl ?? null,
+ pathTemplate: sitemap.templates.blog ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
+ })
+ : null;
+
+ return {
+ ...serialized,
+ schema: { jsonLd },
};
}
@@ -608,6 +681,7 @@ export class BlogsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
+ ...readSeoMetaFromMetadata(metadata),
authorId: blog.author_id?.toString() ?? null,
author: blog.author
? {
diff --git a/src/blogs/dto/blog.dto.ts b/src/blogs/dto/blog.dto.ts
index 77a6422..9af2e3c 100644
--- a/src/blogs/dto/blog.dto.ts
+++ b/src/blogs/dto/blog.dto.ts
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
+ MaxLength,
} from 'class-validator';
export class ListBlogsDto {
@@ -107,6 +108,16 @@ export class CreateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@@ -152,6 +163,16 @@ export class UpdateBlogDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts
index 15d9f85..0d7cbc1 100644
--- a/src/business-settings/business-settings.service.ts
+++ b/src/business-settings/business-settings.service.ts
@@ -17,6 +17,7 @@ import {
sanitizeBusinessSettingsForClient,
toPrismaJson,
} from './business-settings.util';
+import { normalizeWebsiteSchemaSettings } from './website-schema.util';
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
@@ -190,6 +191,17 @@ export class BusinessSettingsService {
specialProductsSource:
dto.website.specialProductsSource ??
current.website.specialProductsSource,
+ schema:
+ dto.website.schema !== undefined
+ ? normalizeWebsiteSchemaSettings({
+ ...current.website.schema,
+ ...dto.website.schema,
+ sameAs:
+ dto.website.schema.sameAs !== undefined
+ ? dto.website.schema.sameAs
+ : current.website.schema.sameAs,
+ })
+ : current.website.schema,
};
}
diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts
index 697a2aa..d73e145 100644
--- a/src/business-settings/business-settings.types.ts
+++ b/src/business-settings/business-settings.types.ts
@@ -111,6 +111,43 @@ export type WebsiteSettings = {
specialProductsSource: SpecialProductsSource;
/** Imported from GET https://{domain}/meshkee/sitemap-config.json */
sitemapConfig?: WebsiteSitemapConfig | null;
+ /** Site-wide Schema.org Organization / LocalBusiness settings (Phase A). */
+ schema: WebsiteSchemaSettings;
+};
+
+/** Schema.org @type for the site-wide entity JSON-LD. */
+export const WEBSITE_SCHEMA_TYPE_IDS = ['Organization', 'LocalBusiness'] as const;
+
+export type WebsiteSchemaType = (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
+
+/**
+ * Business-admin overrides for site-wide structured data.
+ * Empty name/description fall back to business profile fields on the public API.
+ */
+export type WebsiteSchemaSettings = {
+ /** When false, websites should not emit Organization/LocalBusiness JSON-LD. */
+ enabled: boolean;
+ type: WebsiteSchemaType;
+ /** Optional override; empty → business `name`. */
+ name: string | null;
+ /** Optional override; empty → business `nameFa`. */
+ nameFa: string | null;
+ /** Optional override; empty → business `about`. */
+ description: string | null;
+ /** Optional LocalBusiness priceRange (e.g. "$$" or "۱۰۰٬۰۰۰–۵٬۰۰۰٬۰۰۰ تومان"). */
+ priceRange: string | null;
+ /** Extra sameAs profile URLs (merged with social links from business profile). */
+ sameAs: string[];
+};
+
+export const DEFAULT_WEBSITE_SCHEMA_SETTINGS: WebsiteSchemaSettings = {
+ enabled: true,
+ type: 'Organization',
+ name: null,
+ nameFa: null,
+ description: null,
+ priceRange: null,
+ sameAs: [],
};
export type SitemapChangefreq =
@@ -322,6 +359,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
},
website: {
specialProductsSource: 'store_item',
+ schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
},
sms: {
senderNumber: null,
diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts
index 904cffb..82cce51 100644
--- a/src/business-settings/business-settings.util.ts
+++ b/src/business-settings/business-settings.util.ts
@@ -33,11 +33,13 @@ import {
StubGatewaySettings,
WebsiteSitemapConfig,
ZarinpalGatewaySettings,
+ DEFAULT_WEBSITE_SCHEMA_SETTINGS,
} from './business-settings.types';
import {
defaultOrderStepColor,
normalizeOrderStepColor,
} from './order-step-colors';
+import { normalizeWebsiteSchemaSettings } from './website-schema.util';
function isRecord(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -441,6 +443,9 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
enabledModules,
),
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
+ schema: normalizeWebsiteSchemaSettings(
+ isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
+ ),
},
sms: {
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
@@ -495,6 +500,10 @@ export function mergeBusinessSettings(
patch.website?.sitemapConfig !== undefined
? patch.website.sitemapConfig
: current.website.sitemapConfig,
+ schema:
+ patch.website?.schema !== undefined
+ ? patch.website.schema
+ : current.website.schema,
},
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 8a6abf6..0f595d8 100644
--- a/src/business-settings/dto/update-business-settings.dto.ts
+++ b/src/business-settings/dto/update-business-settings.dto.ts
@@ -18,6 +18,7 @@ import {
HOME_CHART_IDS,
PAYMENT_GATEWAY_IDS,
SPECIAL_PRODUCTS_SOURCE_IDS,
+ WEBSITE_SCHEMA_TYPE_IDS,
} from '../business-settings.types';
class BrandingSettingsDto {
@@ -199,11 +200,49 @@ class ModulesSettingsDto {
charts?: string[];
}
+class WebsiteSchemaSettingsDto {
+ @IsOptional()
+ @IsBoolean()
+ enabled?: boolean;
+
+ @IsOptional()
+ @IsString()
+ @IsIn([...WEBSITE_SCHEMA_TYPE_IDS])
+ type?: (typeof WEBSITE_SCHEMA_TYPE_IDS)[number];
+
+ @IsOptional()
+ @IsString()
+ name?: string | null;
+
+ @IsOptional()
+ @IsString()
+ nameFa?: string | null;
+
+ @IsOptional()
+ @IsString()
+ description?: string | null;
+
+ @IsOptional()
+ @IsString()
+ priceRange?: string | null;
+
+ @IsOptional()
+ @IsArray()
+ @IsString({ each: true })
+ @ArrayMaxSize(20)
+ sameAs?: string[];
+}
+
class WebsiteSettingsDto {
@IsOptional()
@IsString()
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
+
+ @IsOptional()
+ @ValidateNested()
+ @Type(() => WebsiteSchemaSettingsDto)
+ schema?: WebsiteSchemaSettingsDto;
}
export class UpdateBusinessSettingsDto {
diff --git a/src/business-settings/website-entity-schema.util.ts b/src/business-settings/website-entity-schema.util.ts
new file mode 100644
index 0000000..98d9b0c
--- /dev/null
+++ b/src/business-settings/website-entity-schema.util.ts
@@ -0,0 +1,167 @@
+import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
+import { slugifyForUrl } from '../sitemap/seo-slug.util';
+import {
+ applyPathTemplate,
+ buildAbsoluteUrl,
+} from '../sitemap/sitemap-xml.util';
+
+export type WebsiteEntityJsonLd = Record;
+
+function plainText(value: string | null | undefined, maxLen = 5000): string {
+ if (!value) return '';
+ return value
+ .replace(/<[^>]+>/g, ' ')
+ .replace(/ /gi, ' ')
+ .replace(/\s+/g, ' ')
+ .trim()
+ .slice(0, maxLen);
+}
+
+function absoluteSiteUrl(host: string): string {
+ return `https://${host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
+}
+
+/**
+ * Product detail JSON-LD for storefronts.
+ * Offer is included when a store min price exists.
+ */
+export function buildProductJsonLd(input: {
+ host: string;
+ id: string;
+ title: string;
+ nameFa?: string | null;
+ summary?: string | null;
+ descriptionHtml?: string | null;
+ imageUrls: string[];
+ brandName?: string | null;
+ pathTemplate?: string;
+ store?: {
+ minPrice: number | null;
+ maxPrice?: number | null;
+ inStock: boolean;
+ } | null;
+}): WebsiteEntityJsonLd {
+ const baseUrl = absoluteSiteUrl(input.host);
+ const name =
+ input.nameFa?.trim() || input.title.trim() || `Product ${input.id}`;
+ const pathSlug = slugifyForUrl(input.nameFa?.trim() || input.title, 'product');
+ const path = applyPathTemplate(
+ input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
+ { id: input.id, slug: pathSlug },
+ );
+ const url = buildAbsoluteUrl(baseUrl, path);
+ const description =
+ plainText(input.summary) ||
+ plainText(input.descriptionHtml) ||
+ undefined;
+ const images = input.imageUrls.map((u) => u.trim()).filter(Boolean);
+
+ const jsonLd: WebsiteEntityJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'Product',
+ name,
+ url,
+ sku: input.id,
+ };
+
+ if (description) jsonLd.description = description;
+ if (images.length === 1) jsonLd.image = images[0];
+ if (images.length > 1) jsonLd.image = images;
+
+ const brandName = input.brandName?.trim();
+ if (brandName) {
+ jsonLd.brand = {
+ '@type': 'Brand',
+ name: brandName,
+ };
+ }
+
+ const minPrice = input.store?.minPrice;
+ if (typeof minPrice === 'number' && Number.isFinite(minPrice) && minPrice >= 0) {
+ jsonLd.offers = {
+ '@type': 'Offer',
+ url,
+ priceCurrency: 'IRR',
+ price: String(minPrice),
+ availability: input.store?.inStock
+ ? 'https://schema.org/InStock'
+ : 'https://schema.org/OutOfStock',
+ };
+ }
+
+ return jsonLd;
+}
+
+/**
+ * Blog detail JSON-LD (BlogPosting) for storefronts.
+ */
+export function buildBlogPostingJsonLd(input: {
+ host: string;
+ id: string;
+ title: string;
+ slug: string;
+ abstract?: string | null;
+ mainTextHtml?: string | null;
+ titleImageUrl?: string | null;
+ publishedAt?: Date | string | null;
+ updatedAt?: Date | string | null;
+ authorName?: string | null;
+ publisherName?: string | null;
+ publisherLogoUrl?: string | null;
+ pathTemplate?: string;
+}): WebsiteEntityJsonLd {
+ const baseUrl = absoluteSiteUrl(input.host);
+ const path = applyPathTemplate(
+ input.pathTemplate ?? DEFAULT_SITEMAP_PATH_TEMPLATES.blog,
+ { id: input.id, slug: input.slug },
+ );
+ const url = buildAbsoluteUrl(baseUrl, path);
+ const description =
+ plainText(input.abstract) || plainText(input.mainTextHtml) || undefined;
+
+ const jsonLd: WebsiteEntityJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'BlogPosting',
+ headline: input.title.trim() || `Post ${input.id}`,
+ mainEntityOfPage: {
+ '@type': 'WebPage',
+ '@id': url,
+ },
+ url,
+ };
+
+ if (description) jsonLd.description = description;
+ if (input.titleImageUrl?.trim()) jsonLd.image = input.titleImageUrl.trim();
+
+ if (input.publishedAt) {
+ jsonLd.datePublished = new Date(input.publishedAt).toISOString();
+ }
+ if (input.updatedAt) {
+ jsonLd.dateModified = new Date(input.updatedAt).toISOString();
+ }
+
+ const authorName = input.authorName?.trim();
+ if (authorName) {
+ jsonLd.author = {
+ '@type': 'Person',
+ name: authorName,
+ };
+ }
+
+ const publisherName = input.publisherName?.trim();
+ if (publisherName) {
+ const publisher: WebsiteEntityJsonLd = {
+ '@type': 'Organization',
+ name: publisherName,
+ };
+ if (input.publisherLogoUrl?.trim()) {
+ publisher.logo = {
+ '@type': 'ImageObject',
+ url: input.publisherLogoUrl.trim(),
+ };
+ }
+ jsonLd.publisher = publisher;
+ }
+
+ return jsonLd;
+}
diff --git a/src/business-settings/website-schema.util.ts b/src/business-settings/website-schema.util.ts
new file mode 100644
index 0000000..23d02fb
--- /dev/null
+++ b/src/business-settings/website-schema.util.ts
@@ -0,0 +1,195 @@
+import {
+ DEFAULT_WEBSITE_SCHEMA_SETTINGS,
+ WEBSITE_SCHEMA_TYPE_IDS,
+ type WebsiteSchemaSettings,
+ type WebsiteSchemaType,
+} from './business-settings.types';
+import type { BusinessSocialMedia } from '../business-profile/business-profile.types';
+
+function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+function readTrimmed(value: unknown): string | null {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed.length > 0 ? trimmed : null;
+}
+
+function normalizeSameAs(raw: unknown): string[] {
+ if (!Array.isArray(raw)) return [];
+ const seen = new Set();
+ const out: string[] = [];
+ for (const item of raw) {
+ if (typeof item !== 'string') continue;
+ const url = item.trim();
+ if (!url || seen.has(url)) continue;
+ if (!/^https?:\/\//i.test(url)) continue;
+ seen.add(url);
+ out.push(url);
+ if (out.length >= 20) break;
+ }
+ return out;
+}
+
+export function normalizeWebsiteSchemaSettings(
+ raw: unknown,
+): WebsiteSchemaSettings {
+ const source = isRecord(raw) ? raw : {};
+ const typeRaw = typeof source.type === 'string' ? source.type : '';
+ const type = (WEBSITE_SCHEMA_TYPE_IDS as readonly string[]).includes(typeRaw)
+ ? (typeRaw as WebsiteSchemaType)
+ : DEFAULT_WEBSITE_SCHEMA_SETTINGS.type;
+
+ return {
+ enabled:
+ typeof source.enabled === 'boolean'
+ ? source.enabled
+ : DEFAULT_WEBSITE_SCHEMA_SETTINGS.enabled,
+ type,
+ name: readTrimmed(source.name),
+ nameFa: readTrimmed(source.nameFa),
+ description: readTrimmed(source.description),
+ priceRange: readTrimmed(source.priceRange),
+ sameAs: normalizeSameAs(source.sameAs),
+ };
+}
+
+/** Turn social handles / URLs into absolute profile URLs for schema.org sameAs. */
+export function socialMediaToSameAsUrls(social: BusinessSocialMedia): string[] {
+ const urls: string[] = [];
+
+ const push = (value: string, build: (handle: string) => string) => {
+ const trimmed = value.trim();
+ if (!trimmed) return;
+ if (/^https?:\/\//i.test(trimmed)) {
+ urls.push(trimmed);
+ return;
+ }
+ const handle = trimmed.replace(/^@/, '').replace(/^\/+/, '');
+ if (!handle) return;
+ urls.push(build(handle));
+ };
+
+ push(social.instagram, (h) => `https://www.instagram.com/${h}`);
+ push(social.telegram, (h) =>
+ h.startsWith('t.me/') ? `https://${h}` : `https://t.me/${h}`,
+ );
+ push(social.linkedin, (h) =>
+ h.includes('linkedin.com') ? (h.startsWith('http') ? h : `https://${h}`) : `https://www.linkedin.com/in/${h}`,
+ );
+ push(social.youtube, (h) =>
+ h.includes('youtube.com') || h.includes('youtu.be')
+ ? h.startsWith('http')
+ ? h
+ : `https://${h}`
+ : `https://www.youtube.com/@${h}`,
+ );
+ push(social.aparat, (h) =>
+ h.includes('aparat.com')
+ ? h.startsWith('http')
+ ? h
+ : `https://${h}`
+ : `https://www.aparat.com/${h}`,
+ );
+
+ // WhatsApp is a phone link, not a public profile — skip for sameAs.
+
+ return normalizeSameAs(urls);
+}
+
+export type WebsiteSchemaJsonLd = Record;
+
+export type BuildWebsiteSchemaJsonLdInput = {
+ settings: WebsiteSchemaSettings;
+ host: string;
+ name: string;
+ nameFa: string;
+ about: string;
+ logoUrl: string | null;
+ emails: string[];
+ phoneNumbers: Array<{ type: string; number: string }>;
+ socialMedia: BusinessSocialMedia;
+ addresses: Array<{
+ province: string;
+ city: string;
+ address: string;
+ postalCode: string | null;
+ landline: string | null;
+ }>;
+};
+
+/**
+ * Build site-wide Organization / LocalBusiness JSON-LD for storefronts.
+ * Returns null when schema is disabled.
+ */
+export function buildWebsiteSchemaJsonLd(
+ input: BuildWebsiteSchemaJsonLdInput,
+): WebsiteSchemaJsonLd | null {
+ const { settings } = input;
+ if (!settings.enabled) return null;
+
+ const displayName =
+ settings.nameFa ||
+ input.nameFa?.trim() ||
+ settings.name ||
+ input.name?.trim() ||
+ input.host;
+
+ const description =
+ settings.description || input.about?.trim() || undefined;
+
+ const sameAs = normalizeSameAs([
+ ...socialMediaToSameAsUrls(input.socialMedia),
+ ...settings.sameAs,
+ ]);
+
+ const telephone =
+ input.phoneNumbers.find((p) => p.number.trim())?.number.trim() ||
+ input.addresses.find((a) => a.landline?.trim())?.landline?.trim() ||
+ undefined;
+
+ const email = input.emails.find((e) => e.trim())?.trim() || undefined;
+
+ const url = `https://${input.host.replace(/^https?:\/\//i, '').replace(/\/+$/, '')}`;
+
+ const jsonLd: WebsiteSchemaJsonLd = {
+ '@context': 'https://schema.org',
+ '@type': settings.type,
+ name: displayName,
+ url,
+ };
+
+ if (description) jsonLd.description = description;
+ if (input.logoUrl) {
+ jsonLd.logo = input.logoUrl;
+ jsonLd.image = input.logoUrl;
+ }
+ if (telephone) jsonLd.telephone = telephone;
+ if (email) jsonLd.email = email;
+ if (sameAs.length > 0) jsonLd.sameAs = sameAs;
+
+ const primaryAddress = input.addresses[0];
+ if (primaryAddress) {
+ const street = primaryAddress.address?.trim();
+ const city = primaryAddress.city?.trim();
+ const province = primaryAddress.province?.trim();
+ const postalCode = primaryAddress.postalCode?.trim() || undefined;
+ if (street || city || province || postalCode) {
+ jsonLd.address = {
+ '@type': 'PostalAddress',
+ ...(street ? { streetAddress: street } : {}),
+ ...(city ? { addressLocality: city } : {}),
+ ...(province ? { addressRegion: province } : {}),
+ ...(postalCode ? { postalCode } : {}),
+ addressCountry: 'IR',
+ };
+ }
+ }
+
+ if (settings.type === 'LocalBusiness' && settings.priceRange) {
+ jsonLd.priceRange = settings.priceRange;
+ }
+
+ return jsonLd;
+}
diff --git a/src/categories/categories.service.ts b/src/categories/categories.service.ts
index b37d920..62621fe 100644
--- a/src/categories/categories.service.ts
+++ b/src/categories/categories.service.ts
@@ -6,16 +6,29 @@ import {
} from '@nestjs/common';
import {
MediaEntityType,
+ Prisma,
TranslationEntityType,
TranslationStatus,
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
+function asRecord(value: Prisma.JsonValue): Record {
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
+ return value as Record;
+ }
+ return {};
+}
+
function slugify(value: string): string {
return (
value
@@ -157,6 +170,10 @@ export class CategoriesService {
slug,
description: dto.description?.trim() || null,
sortOrder: dto.sortOrder ?? 0,
+ metadata: applySeoMetaToMetadata(
+ {},
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
});
@@ -237,6 +254,14 @@ export class CategoriesService {
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
...(parentId !== undefined ? { parentId } : {}),
+ ...(dto.seoMetaTitle !== undefined || dto.seoMetaDescription !== undefined
+ ? {
+ metadata: applySeoMetaToMetadata(
+ asRecord(existing.metadata),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
+ }
+ : {}),
slug,
},
});
@@ -395,6 +420,7 @@ export class CategoriesService {
nameFa: string | null;
slug: string;
description: string | null;
+ metadata?: Prisma.JsonValue;
sortOrder: number;
isActive: boolean;
createdAt: Date;
@@ -412,6 +438,7 @@ export class CategoriesService {
nameFa: category.nameFa,
slug: category.slug,
description: category.description,
+ ...readSeoMetaFromMetadata(asRecord(category.metadata ?? {})),
sortOrder: category.sortOrder,
isActive: category.isActive,
variationCount,
diff --git a/src/categories/dto/category.dto.ts b/src/categories/dto/category.dto.ts
index 668f5ac..d4e9cfe 100644
--- a/src/categories/dto/category.dto.ts
+++ b/src/categories/dto/category.dto.ts
@@ -1,6 +1,15 @@
import { MediaEntityType } from '@prisma/client';
import { Type } from 'class-transformer';
-import { IsEnum, IsInt, IsOptional, IsString, Matches, Min, MinLength } from 'class-validator';
+import {
+ IsEnum,
+ IsInt,
+ IsOptional,
+ IsString,
+ Matches,
+ MaxLength,
+ Min,
+ MinLength,
+} from 'class-validator';
export class ListCategoriesDto {
@IsOptional()
@@ -29,6 +38,16 @@ export class CreateCategoryDto {
@IsString()
description?: string;
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
@@ -61,6 +80,16 @@ export class UpdateCategoryDto {
@IsString()
description?: string | null;
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
diff --git a/src/common/seo-meta.dto.ts b/src/common/seo-meta.dto.ts
new file mode 100644
index 0000000..c0aea09
--- /dev/null
+++ b/src/common/seo-meta.dto.ts
@@ -0,0 +1,23 @@
+import { IsOptional, IsString, MaxLength } from 'class-validator';
+import {
+ SEO_META_DESCRIPTION_MAX,
+ SEO_META_TITLE_MAX,
+} from '../common/seo-meta.util';
+
+/** Optional mixin fields — copy onto Create/Update DTOs (Nest does not support multi-inheritance well). */
+export const SEO_META_DTO_DECORATORS = {
+ titleMax: SEO_META_TITLE_MAX,
+ descriptionMax: SEO_META_DESCRIPTION_MAX,
+} as const;
+
+export class SeoMetaDto {
+ @IsOptional()
+ @IsString()
+ @MaxLength(SEO_META_TITLE_MAX)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(SEO_META_DESCRIPTION_MAX)
+ seoMetaDescription?: string | null;
+}
diff --git a/src/common/seo-meta.util.ts b/src/common/seo-meta.util.ts
new file mode 100644
index 0000000..c7e74ae
--- /dev/null
+++ b/src/common/seo-meta.util.ts
@@ -0,0 +1,67 @@
+/**
+ * Per-page SEO overrides stored in entity `metadata` JSON.
+ * Empty / null → websites fall back to title / summary / about fields.
+ */
+
+export const SEO_META_TITLE_MAX = 70;
+export const SEO_META_DESCRIPTION_MAX = 160;
+
+export type SeoMetaFields = {
+ seoMetaTitle: string | null;
+ seoMetaDescription: string | null;
+};
+
+export function emptySeoMeta(): SeoMetaFields {
+ return { seoMetaTitle: null, seoMetaDescription: null };
+}
+
+export function readSeoMetaFromMetadata(metadata: Record): SeoMetaFields {
+ const title =
+ typeof metadata.seoMetaTitle === 'string' ? metadata.seoMetaTitle.trim() : '';
+ const description =
+ typeof metadata.seoMetaDescription === 'string'
+ ? metadata.seoMetaDescription.trim()
+ : '';
+ return {
+ seoMetaTitle: title ? title.slice(0, SEO_META_TITLE_MAX) : null,
+ seoMetaDescription: description
+ ? description.slice(0, SEO_META_DESCRIPTION_MAX)
+ : null,
+ };
+}
+
+/** Normalize optional DTO strings (undefined = leave unchanged when merging). */
+export function normalizeSeoMetaInput(
+ seoMetaTitle?: string | null,
+ seoMetaDescription?: string | null,
+): Partial {
+ const out: Partial = {};
+ if (seoMetaTitle !== undefined) {
+ const trimmed = seoMetaTitle?.trim() ?? '';
+ out.seoMetaTitle = trimmed ? trimmed.slice(0, SEO_META_TITLE_MAX) : null;
+ }
+ if (seoMetaDescription !== undefined) {
+ const trimmed = seoMetaDescription?.trim() ?? '';
+ out.seoMetaDescription = trimmed
+ ? trimmed.slice(0, SEO_META_DESCRIPTION_MAX)
+ : null;
+ }
+ return out;
+}
+
+/** Merge SEO keys into a metadata object (preserves tags and other keys). */
+export function applySeoMetaToMetadata(
+ metadata: Record,
+ seo: Partial,
+): Record {
+ const next = { ...metadata };
+ if (seo.seoMetaTitle !== undefined) {
+ if (seo.seoMetaTitle) next.seoMetaTitle = seo.seoMetaTitle;
+ else delete next.seoMetaTitle;
+ }
+ if (seo.seoMetaDescription !== undefined) {
+ if (seo.seoMetaDescription) next.seoMetaDescription = seo.seoMetaDescription;
+ else delete next.seoMetaDescription;
+ }
+ return next;
+}
diff --git a/src/customers/customers.service.ts b/src/customers/customers.service.ts
index 1d47094..e149ae4 100644
--- a/src/customers/customers.service.ts
+++ b/src/customers/customers.service.ts
@@ -1,4 +1,12 @@
-import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException, ServiceUnavailableException } from '@nestjs/common';
+import {
+ BadRequestException,
+ ConflictException,
+ ForbiddenException,
+ HttpException,
+ Injectable,
+ NotFoundException,
+ ServiceUnavailableException,
+} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { randomBytes } from 'crypto';
@@ -769,7 +777,8 @@ export class CustomersService {
/**
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
- * With a personalized template, messages are sent per-recipient (not one bulk payload).
+ * Always sends per-recipient so one failure does not abort the rest.
+ * Returns a report with sent/failed counts and per-number errors.
*/
async sendBulkSms(
businessIdRaw: string,
@@ -863,139 +872,137 @@ export class CustomersService {
enabled: false,
message: 'SMS is disabled. Message was not sent.',
recipientCount: recipients.length,
+ sentCount: 0,
+ failedCount: 0,
+ failures: [],
};
}
- const sample = await this.resolveOutboundSms(
- businessId,
- business.settings,
- dto.message,
- dto.templateId,
- {
- firstName: recipients[0].firstName,
- lastName: recipients[0].lastName,
- titleName: recipients[0].titleName,
- },
- );
-
- const personalized =
- Boolean(dto.templateId) || smsTemplateUsesVariables(dto.message);
-
- let charged = 0;
- let personalizedPayloads: Array<{
+ const payloads: Array<{
cellNumber: string;
message: string;
source: string;
cost: number;
+ templateId: string | null;
+ templateVerified: boolean | null;
}> = [];
- if (!personalized) {
- charged = await this.smsBilling.charge(
+ for (const recipient of recipients) {
+ const resolved = await this.resolveOutboundSms(
businessId,
- sample.message,
- recipients.length,
- 'mass',
- );
- } else {
- personalizedPayloads = [];
- let totalCost = 0;
- for (const recipient of recipients) {
- const resolved = await this.resolveOutboundSms(
- businessId,
- business.settings,
- dto.message,
- dto.templateId,
- {
- firstName: recipient.firstName,
- lastName: recipient.lastName,
- titleName: recipient.titleName,
- },
- );
- const cost = this.smsBilling.costForMessage(resolved.message);
- personalizedPayloads.push({
- cellNumber: recipient.cellNumber,
- message: resolved.message,
- source: resolved.source,
- cost,
- });
- totalCost += cost;
- }
- charged = await this.smsBilling.chargeAmount(
- businessId,
- totalCost,
- personalizedPayloads.length,
- 'mass',
+ business.settings,
+ dto.message,
+ dto.templateId,
+ {
+ firstName: recipient.firstName,
+ lastName: recipient.lastName,
+ titleName: recipient.titleName,
+ },
);
+ payloads.push({
+ cellNumber: recipient.cellNumber,
+ message: resolved.message,
+ source: resolved.source,
+ cost: this.smsBilling.costForMessage(resolved.message),
+ templateId: resolved.templateId,
+ templateVerified: resolved.templateVerified,
+ });
}
- try {
- if (!personalized) {
- const result = await this.sms.sendBulkMessage(
- recipients.map((r) => r.cellNumber),
- sample.message,
- { source: sample.source },
- );
- return {
- enabled: true,
- message: 'Message sent successfully',
- recipientCount: result.recipientCount,
- serverIds: result.serverIds,
- source: sample.source,
- templateId: sample.templateId,
- templateVerified: sample.templateVerified,
- };
+ const serverIds: string[] = [];
+ const failures: Array<{ cellNumber: string; error: string }> = [];
+ let sentCount = 0;
+ let balanceExhausted = false;
+
+ for (const payload of payloads) {
+ if (balanceExhausted) {
+ failures.push({
+ cellNumber: payload.cellNumber,
+ error: 'Insufficient SMS balance. Contact Meshkee to top up.',
+ });
+ continue;
}
- const serverIds: string[] = [];
- for (let i = 0; i < personalizedPayloads.length; i++) {
- const payload = personalizedPayloads[i];
- try {
- const result = await this.sms.sendMessage(
- payload.cellNumber,
- payload.message,
- { source: payload.source },
- );
- serverIds.push(result.serverId);
- } catch (err) {
- const remaining = personalizedPayloads.slice(i);
- const refundAmount = remaining.reduce((sum, item) => sum + item.cost, 0);
- await this.smsBilling.refund(
- businessId,
- refundAmount,
- remaining.length,
- 'mass',
- );
- charged = 0;
- throw err;
- }
- }
-
- return {
- enabled: true,
- message: 'Message sent successfully',
- recipientCount: serverIds.length,
- serverIds,
- source: sample.source,
- templateId: sample.templateId,
- templateVerified: sample.templateVerified,
- };
- } catch (err) {
- if (charged > 0) {
- await this.smsBilling.refund(
+ let charged = 0;
+ try {
+ charged = await this.smsBilling.chargeAmount(
businessId,
- charged,
- personalized ? personalizedPayloads.length : recipients.length,
+ payload.cost,
+ 1,
'mass',
);
+ } catch (err) {
+ const message = this.smsFailureMessage(err);
+ if (message.toLowerCase().includes('insufficient sms balance')) {
+ balanceExhausted = true;
+ }
+ failures.push({ cellNumber: payload.cellNumber, error: message });
+ continue;
}
- if (
- err instanceof BadRequestException ||
- err instanceof ServiceUnavailableException
- ) {
- throw err;
+
+ try {
+ const result = await this.sms.sendMessage(
+ payload.cellNumber,
+ payload.message,
+ { source: payload.source },
+ );
+ serverIds.push(result.serverId);
+ sentCount += 1;
+ } catch (err) {
+ if (charged > 0) {
+ await this.smsBilling.refund(businessId, charged, 1, 'mass');
+ }
+ failures.push({
+ cellNumber: payload.cellNumber,
+ error: this.smsFailureMessage(err),
+ });
}
- throw new ServiceUnavailableException('Unable to send SMS');
}
+
+ const sample = payloads[0];
+ const failedCount = failures.length;
+ const message =
+ failedCount === 0
+ ? 'Message sent successfully'
+ : sentCount === 0
+ ? 'No messages were sent'
+ : `Sent ${sentCount} of ${payloads.length}; ${failedCount} failed`;
+
+ return {
+ enabled: true,
+ message,
+ recipientCount: payloads.length,
+ sentCount,
+ failedCount,
+ serverIds,
+ failures,
+ source: sample?.source,
+ templateId: sample?.templateId,
+ templateVerified: sample?.templateVerified,
+ };
+ }
+
+ private smsFailureMessage(err: unknown): string {
+ if (err instanceof HttpException) {
+ const response = err.getResponse();
+ if (typeof response === 'string' && response.trim()) {
+ return response.trim();
+ }
+ if (typeof response === 'object' && response !== null && 'message' in response) {
+ const message = (response as { message?: string | string[] }).message;
+ if (Array.isArray(message)) {
+ return message.filter(Boolean).join(', ') || err.message;
+ }
+ if (typeof message === 'string' && message.trim()) {
+ return message.trim();
+ }
+ }
+ return err.message || 'Unable to send SMS';
+ }
+ if (err instanceof Error && err.message.trim()) {
+ return err.message.trim();
+ }
+ return 'Unable to send SMS';
}
/**
diff --git a/src/instructions/dto/instruction.dto.ts b/src/instructions/dto/instruction.dto.ts
index b7fd07b..2fe4743 100644
--- a/src/instructions/dto/instruction.dto.ts
+++ b/src/instructions/dto/instruction.dto.ts
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
+ MaxLength,
} from 'class-validator';
export class ListInstructionsDto {
@@ -114,6 +115,16 @@ export class CreateInstructionDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@@ -173,6 +184,16 @@ export class UpdateInstructionDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
diff --git a/src/instructions/instructions.service.ts b/src/instructions/instructions.service.ts
index c16c9f0..75df406 100644
--- a/src/instructions/instructions.service.ts
+++ b/src/instructions/instructions.service.ts
@@ -13,6 +13,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -166,7 +171,10 @@ export class InstructionsService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
include: instructionInclude,
});
@@ -260,10 +268,13 @@ export class InstructionsService {
nextContent.html = dto.descriptionHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -661,6 +672,7 @@ export class InstructionsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
+ ...readSeoMetaFromMetadata(metadata),
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
diff --git a/src/portfolios/dto/portfolio.dto.ts b/src/portfolios/dto/portfolio.dto.ts
index feb96ca..9710e2f 100644
--- a/src/portfolios/dto/portfolio.dto.ts
+++ b/src/portfolios/dto/portfolio.dto.ts
@@ -10,6 +10,7 @@ import {
Matches,
Min,
MinLength,
+ MaxLength,
} from 'class-validator';
export class ListPortfoliosDto {
@@ -113,6 +114,16 @@ export class CreatePortfolioDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsArray()
@@ -174,6 +185,16 @@ export class UpdatePortfolioDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsArray()
diff --git a/src/portfolios/portfolios.service.ts b/src/portfolios/portfolios.service.ts
index dfb8e1e..47043a6 100644
--- a/src/portfolios/portfolios.service.ts
+++ b/src/portfolios/portfolios.service.ts
@@ -12,6 +12,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { SitemapService } from '../sitemap/sitemap.service';
@@ -166,7 +171,10 @@ export class PortfoliosService {
featured_media_id: featuredMediaId,
sort_order: sortOrder,
published_at: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
include: portfolioInclude,
});
@@ -265,10 +273,13 @@ export class PortfoliosService {
nextContent.html = dto.mainTextHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -786,6 +797,7 @@ export class PortfoliosService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
+ ...readSeoMetaFromMetadata(metadata),
titleImageUrl:
portfolio.media?.publicUrl ??
galleryAttachments[0]?.media.publicUrl ??
diff --git a/src/products/dto/product.dto.ts b/src/products/dto/product.dto.ts
index 0698559..07c7735 100644
--- a/src/products/dto/product.dto.ts
+++ b/src/products/dto/product.dto.ts
@@ -11,6 +11,7 @@ import {
Matches,
Min,
MinLength,
+ MaxLength,
} from 'class-validator';
export class ListProductsDto {
@@ -112,6 +113,17 @@ export class CreateProductDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
+
@IsOptional()
@IsString()
@@ -162,6 +174,17 @@ export class UpdateProductDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
+
@IsOptional()
@IsString()
diff --git a/src/products/products.service.ts b/src/products/products.service.ts
index 72903eb..cfc7ff2 100644
--- a/src/products/products.service.ts
+++ b/src/products/products.service.ts
@@ -14,8 +14,17 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BrandsService } from '../brands/brands.service';
+import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
+import { buildProductJsonLd } from '../business-settings/website-entity-schema.util';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
+import { DEFAULT_SITEMAP_PATH_TEMPLATES } from '../sitemap/sitemap.constants';
+import { resolveSitemapConfig } from '../sitemap/sitemap-config.util';
import { SitemapService } from '../sitemap/sitemap.service';
import { TenantService } from '../tenant/tenant.service';
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
@@ -189,7 +198,7 @@ export class ProductsService {
throw new NotFoundException('Product not found');
}
- return this.serializePublicDetail(businessId, product);
+ return this.serializePublicDetail(host, businessId, product);
}
async getPublicById(host: string, productIdRaw: string) {
@@ -215,20 +224,80 @@ export class ProductsService {
throw new NotFoundException('Product not found');
}
- return this.serializePublicDetail(businessId, product);
+ return this.serializePublicDetail(host, businessId, product);
}
private async serializePublicDetail(
+ host: string,
businessId: bigint,
product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>,
) {
- const [serializedList, relatedProducts] = await Promise.all([
+ const [serializedList, relatedProducts, business] = await Promise.all([
this.serializePublicListItems(businessId, [product]),
this.loadRelatedPublicProducts(businessId, product),
+ this.prisma.business.findUnique({
+ where: { id: businessId },
+ select: {
+ settings: true,
+ name: true,
+ nameFa: true,
+ domains: {
+ where: { isPrimary: true },
+ select: { host: true },
+ take: 1,
+ },
+ },
+ }),
]);
+ const productPayload = serializedList[0];
+ const settings = normalizeBusinessSettings(business?.settings);
+ const normalizedHost = host
+ .toLowerCase()
+ .trim()
+ .replace(/^(customer|business)\./, '');
+ const primaryHost = business?.domains[0]?.host ?? normalizedHost;
+ const sitemap = resolveSitemapConfig(
+ settings.website.sitemapConfig ?? null,
+ primaryHost,
+ );
+
+ const imageUrls = [
+ productPayload.thumbnailUrl,
+ ...((productPayload.images as Array<{ url?: string }> | undefined) ?? []).map(
+ (img) => img.url,
+ ),
+ ].filter((url): url is string => Boolean(url?.trim()));
+
+ const jsonLd = settings.website.schema.enabled
+ ? buildProductJsonLd({
+ host: primaryHost,
+ id: productPayload.id,
+ title: productPayload.seoMetaTitle || productPayload.title,
+ nameFa: productPayload.seoMetaTitle
+ ? productPayload.seoMetaTitle
+ : productPayload.nameFa,
+ summary:
+ productPayload.seoMetaDescription || productPayload.summary,
+ descriptionHtml: productPayload.seoMetaDescription
+ ? productPayload.seoMetaDescription
+ : productPayload.descriptionHtml,
+ imageUrls,
+ brandName:
+ productPayload.brand?.nameFa?.trim() ||
+ productPayload.brand?.nameEn?.trim() ||
+ null,
+ pathTemplate:
+ sitemap.templates.product ?? DEFAULT_SITEMAP_PATH_TEMPLATES.product,
+ store: productPayload.store ?? null,
+ })
+ : null;
+
return {
- product: serializedList[0],
+ product: {
+ ...productPayload,
+ schema: { jsonLd },
+ },
relatedProducts,
};
}
@@ -296,7 +365,10 @@ export class ProductsService {
featuredMediaId,
brandId,
publishedAt: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags),
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
include: {
featuredMedia: true,
@@ -379,10 +451,13 @@ export class ProductsService {
nextContent.html = dto.descriptionHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -640,6 +715,7 @@ export class ProductsService {
tags: Array.isArray(metadata.tags)
? (metadata.tags as string[])
: [],
+ ...readSeoMetaFromMetadata(metadata),
thumbnailUrl,
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
image,
diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts
index 0ec9ca9..6f4c2ea 100644
--- a/src/tenant/tenant.service.ts
+++ b/src/tenant/tenant.service.ts
@@ -61,6 +61,10 @@ export class TenantService {
enabledModules: settings.modules.enabled,
homeCharts: settings.modules.charts,
specialProductsSource: settings.website.specialProductsSource,
+ schema: {
+ enabled: settings.website.schema.enabled,
+ type: settings.website.schema.type,
+ },
logoUrl: media?.logoMedia?.publicUrl ?? null,
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
faviconUrl:
diff --git a/src/user-products/dto/user-product.dto.ts b/src/user-products/dto/user-product.dto.ts
index 1a9b333..7854612 100644
--- a/src/user-products/dto/user-product.dto.ts
+++ b/src/user-products/dto/user-product.dto.ts
@@ -9,6 +9,7 @@ import {
IsNumber,
IsOptional,
IsString,
+ MaxLength,
Min,
MinLength,
ValidateNested,
@@ -166,6 +167,16 @@ export class CreateUserProductDto {
@IsArray()
@IsString({ each: true })
galleryMediaIds?: string[];
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
}
export class UpdateUserProductDto extends CreateUserProductDto {}
diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts
index f4b1a65..66ca33a 100644
--- a/src/user-products/user-products.service.ts
+++ b/src/user-products/user-products.service.ts
@@ -14,6 +14,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { CategoryTechnicalFormService } from '../categories/category-technical-form.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import {
@@ -337,9 +342,12 @@ export class UserProductsService {
const content: Prisma.InputJsonValue = {
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
};
- const metadata: Prisma.InputJsonValue = {
- priceByExpert: dto.priceByExpert === true,
- };
+ const metadata = applySeoMetaToMetadata(
+ {
+ priceByExpert: dto.priceByExpert === true,
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue;
const created = await this.prisma.$transaction(async (tx) => {
const product = await tx.userProduct.create({
@@ -522,10 +530,13 @@ export class UserProductsService {
const content: Prisma.InputJsonValue = {
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
};
- const metadata: Prisma.InputJsonValue = {
- ...existingMetadata,
- priceByExpert: dto.priceByExpert === true,
- };
+ const metadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ priceByExpert: dto.priceByExpert === true,
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue;
let slug = existing.slug;
if (titleFa !== existing.title) {
@@ -1269,6 +1280,7 @@ export class UserProductsService {
priceCurrency: product.priceCurrency,
priceByExpert,
promoted,
+ ...readSeoMetaFromMetadata(metadata),
status: product.status,
condition: product.condition,
deliveryNote: product.deliveryNote,
diff --git a/src/videos/dto/video.dto.ts b/src/videos/dto/video.dto.ts
index 74ff71a..fc7aa97 100644
--- a/src/videos/dto/video.dto.ts
+++ b/src/videos/dto/video.dto.ts
@@ -9,6 +9,7 @@ import {
Matches,
Min,
MinLength,
+ MaxLength,
} from 'class-validator';
export class ListVideosDto {
@@ -120,6 +121,16 @@ export class CreateVideoDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@@ -179,6 +190,16 @@ export class UpdateVideoDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
diff --git a/src/videos/videos.service.ts b/src/videos/videos.service.ts
index 5dd8d97..2c3c86c 100644
--- a/src/videos/videos.service.ts
+++ b/src/videos/videos.service.ts
@@ -13,6 +13,11 @@ import {
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
import { BusinessSettingsService } from '../business-settings/business-settings.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
import {
@@ -153,7 +158,10 @@ export class VideosService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
},
include: videoInclude,
});
@@ -247,10 +255,13 @@ export class VideosService {
nextContent.html = dto.descriptionHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -651,6 +662,7 @@ export class VideosService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
+ ...readSeoMetaFromMetadata(metadata),
authorId: video.author_id?.toString() ?? null,
author: video.author
? {
diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md
index bd6ffa2..fdb09d8 100644
--- a/src/website-docs/static/AI_PROMPT.md
+++ b/src/website-docs/static/AI_PROMPT.md
@@ -319,11 +319,73 @@ 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 Schema.org JSON-LD (Organization / LocalBusiness)
+
+Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
+
+1. `GET /tenants/{domain}/website/business-info`
+2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
+
+```html
+
+```
+
+In Next.js App Router, put it in the root layout via `` when `jsonLd` is non-null.
+
+- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
+- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
+- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
+- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
+
+### Product + blog Schema.org JSON-LD (auto)
+
+When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
+
+| Page | Endpoint | `@type` |
+|------|----------|---------|
+| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
+| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
+
+On the product/blog page:
+
+```tsx
+{product.schema?.jsonLd ? (
+
+) : null}
+```
+
+- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
+- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
+- Do not invent Product/Article fields locally when `jsonLd` is present.
+
+### Per-page SEO meta overrides (optional)
+
+CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
+
+Public detail payloads include:
+
+- `seoMetaTitle` (`string | null`)
+- `seoMetaDescription` (`string | null`)
+
+Website rules:
+
+1. Prefer `seoMetaTitle` for `` when non-null; else use the normal title/`nameFa`.
+2. Prefer `seoMetaDescription` for `` when non-null; else summary/abstract/about.
+3. Product/blog `schema.jsonLd` already prefers these overrides when set.
+4. Empty/null means “no override” — never show placeholder text as meta.
+
**Checklist before shipping a page**
-1. Unique `` and meta description
+1. Unique `` and meta description (use `seoMeta*` when present)
2. One clear H1 + sensible H2 sections
3. All content images have alt text
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
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 4450e7b..dd732cc 100644
--- a/src/website-docs/static/openapi.json
+++ b/src/website-docs/static/openapi.json
@@ -195,6 +195,18 @@
"store_item"
],
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
+ },
+ "schema": {
+ "type": "object",
+ "description": "Site-wide Schema.org flags. Full jsonLd is on GET /tenants/{domain}/website/business-info.",
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "type": {
+ "type": "string",
+ "enum": ["Organization", "LocalBusiness"]
+ }
+ },
+ "required": ["enabled", "type"]
}
}
}
@@ -530,7 +542,32 @@
}
},
"socialMedia": { "type": "object" },
- "addresses": { "type": "array", "items": { "type": "object" } }
+ "addresses": { "type": "array", "items": { "type": "object" } },
+ "schema": {
+ "type": "object",
+ "description": "Site-wide Schema.org settings + ready-to-emit jsonLd (Phase A).",
+ "properties": {
+ "enabled": { "type": "boolean" },
+ "type": {
+ "type": "string",
+ "enum": ["Organization", "LocalBusiness"]
+ },
+ "name": { "type": "string", "nullable": true },
+ "nameFa": { "type": "string", "nullable": true },
+ "description": { "type": "string", "nullable": true },
+ "priceRange": { "type": "string", "nullable": true },
+ "sameAs": {
+ "type": "array",
+ "items": { "type": "string" }
+ },
+ "jsonLd": {
+ "type": "object",
+ "nullable": true,
+ "description": "Null when schema.enabled is false. Otherwise emit as application/ld+json on every public page (or at least the layout)."
+ }
+ },
+ "required": ["enabled", "type", "sameAs", "jsonLd"]
+ }
}
}
}
@@ -1001,7 +1038,7 @@
],
"responses": {
"200": {
- "description": "{ product, relatedProducts } — same shape as product-by-slug"
+ "description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema."
}
}
}
@@ -1080,7 +1117,7 @@
],
"responses": {
"200": {
- "description": "{ product, relatedProducts } — relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
+ "description": "{ product, relatedProducts }. `product.schema.jsonLd` is Product Schema.org JSON-LD when website structured data is enabled (null otherwise). Related products omit schema. relatedProducts uses the product list-card shape (incl. store). Ranked by relativity (same category, then same brand) with availability beside it (in-stock first within each group). Up to 8 items; excludes the current product."
}
}
}
@@ -1622,7 +1659,7 @@
],
"responses": {
"200": {
- "description": "{ blog }"
+ "description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
@@ -1648,7 +1685,7 @@
],
"responses": {
"200": {
- "description": "{ blog }"
+ "description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
}
}
}
diff --git a/src/website/website-business-info.service.ts b/src/website/website-business-info.service.ts
index 30ac0e1..2bf0ebf 100644
--- a/src/website/website-business-info.service.ts
+++ b/src/website/website-business-info.service.ts
@@ -4,6 +4,8 @@ import {
normalizePhoneNumbers,
normalizeSocialMedia,
} from '../business-profile/business-profile.util';
+import { normalizeBusinessSettings } from '../business-settings/business-settings.util';
+import { buildWebsiteSchemaJsonLd } from '../business-settings/website-schema.util';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
@@ -16,6 +18,7 @@ export class WebsiteBusinessInfoService {
async getPublic(host: string) {
const business = await this.tenant.resolveBusinessByDomain(host);
+ const settings = normalizeBusinessSettings(business.settings);
const record = await this.prisma.business.findUnique({
where: { id: business.id },
@@ -31,27 +34,61 @@ export class WebsiteBusinessInfoService {
throw new NotFoundException('Business not found');
}
+ const emails = normalizeEmails(record.emails);
+ const phoneNumbers = normalizePhoneNumbers(record.phoneNumbers);
+ const socialMedia = normalizeSocialMedia(record.socialMedia);
+ const addresses = record.addresses.map((address) => ({
+ id: address.id.toString(),
+ province: address.province,
+ city: address.city,
+ address: address.address,
+ postalCode: address.postalCode,
+ landline: address.landline,
+ }));
+ const logoUrl = record.logoMedia?.publicUrl ?? null;
+ const normalizedHost = host
+ .toLowerCase()
+ .trim()
+ .replace(/^(customer|business)\./, '');
+
+ const schemaSettings = settings.website.schema;
+ const jsonLd = buildWebsiteSchemaJsonLd({
+ settings: schemaSettings,
+ host: normalizedHost,
+ name: record.name,
+ nameFa: record.nameFa ?? '',
+ about: record.about ?? '',
+ logoUrl,
+ emails,
+ phoneNumbers,
+ socialMedia,
+ addresses,
+ });
+
return {
id: record.id.toString(),
name: record.name,
nameFa: record.nameFa ?? '',
about: record.about ?? '',
vision: record.vision ?? '',
- logoUrl: record.logoMedia?.publicUrl ?? null,
+ logoUrl,
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
faviconUrl:
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
- emails: normalizeEmails(record.emails),
- phoneNumbers: normalizePhoneNumbers(record.phoneNumbers),
- socialMedia: normalizeSocialMedia(record.socialMedia),
- addresses: record.addresses.map((address) => ({
- id: address.id.toString(),
- province: address.province,
- city: address.city,
- address: address.address,
- postalCode: address.postalCode,
- landline: address.landline,
- })),
+ emails,
+ phoneNumbers,
+ socialMedia,
+ addresses,
+ schema: {
+ enabled: schemaSettings.enabled,
+ type: schemaSettings.type,
+ name: schemaSettings.name,
+ nameFa: schemaSettings.nameFa,
+ description: schemaSettings.description,
+ priceRange: schemaSettings.priceRange,
+ sameAs: schemaSettings.sameAs,
+ jsonLd,
+ },
};
}
}
diff --git a/src/workshops/dto/workshop.dto.ts b/src/workshops/dto/workshop.dto.ts
index 6f01849..b6ee71e 100644
--- a/src/workshops/dto/workshop.dto.ts
+++ b/src/workshops/dto/workshop.dto.ts
@@ -110,6 +110,16 @@ export class CreateWorkshopDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
@@ -182,6 +192,16 @@ export class UpdateWorkshopDto {
@IsArray()
@IsString({ each: true })
tags?: string[];
+ @IsOptional()
+ @IsString()
+ @MaxLength(70)
+ seoMetaTitle?: string | null;
+
+ @IsOptional()
+ @IsString()
+ @MaxLength(160)
+ seoMetaDescription?: string | null;
+
@IsOptional()
@IsString()
diff --git a/src/workshops/workshops.service.ts b/src/workshops/workshops.service.ts
index d66ef38..85c7127 100644
--- a/src/workshops/workshops.service.ts
+++ b/src/workshops/workshops.service.ts
@@ -13,6 +13,11 @@ import {
} from '@prisma/client';
import { AuthUser } from '../auth/auth.types';
import { PermissionsService } from '../auth/permissions.service';
+import {
+ applySeoMetaToMetadata,
+ normalizeSeoMetaInput,
+ readSeoMetaFromMetadata,
+} from '../common/seo-meta.util';
import { MediaService } from '../media/media.service';
import { PrismaService } from '../prisma/prisma.service';
import { TenantService } from '../tenant/tenant.service';
@@ -158,7 +163,10 @@ export class WorkshopsService {
status,
featured_media_id: featuredMediaId,
published_at: status === ContentStatus.published ? new Date() : null,
- metadata: this.buildMetadata(dto.tags) as Prisma.InputJsonValue,
+ metadata: applySeoMetaToMetadata(
+ this.buildMetadata(dto.tags),
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ ) as Prisma.InputJsonValue,
event_date: this.parseEventDate(dto.eventDate) ?? null,
event_time: dto.eventTime?.trim() || null,
duration: dto.duration?.trim() || null,
@@ -262,10 +270,13 @@ export class WorkshopsService {
nextContent.html = dto.descriptionHtml ?? '';
}
- const nextMetadata = { ...existingMetadata };
- if (dto.tags !== undefined) {
- nextMetadata.tags = dto.tags;
- }
+ const nextMetadata = applySeoMetaToMetadata(
+ {
+ ...existingMetadata,
+ ...(dto.tags !== undefined ? { tags: dto.tags } : {}),
+ },
+ normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
+ );
let publishedAt: Date | null | undefined = undefined;
if (dto.status !== undefined) {
@@ -801,6 +812,7 @@ export class WorkshopsService {
categoryId: categoryAssignment?.categoryId.toString() ?? null,
categoryName: categoryAssignment?.category.name ?? '',
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
+ ...readSeoMetaFromMetadata(metadata),
authorId: workshop.author_id?.toString() ?? null,
author: workshop.author
? {