Add Schema.org JSON-LD, SEO meta overrides, and resilient bulk SMS.
Expose Organization/LocalBusiness and Product/BlogPosting jsonLd, optional seoMeta fields on content/categories, and continue mass SMS after per-recipient failures. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
db305c58a4
commit
aaf7a83742
@@ -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';
|
||||||
@@ -319,11 +319,73 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
|||||||
**Open Graph (recommended)**
|
**Open Graph (recommended)**
|
||||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
- 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
|
||||||
|
<script type="application/ld+json">
|
||||||
|
…paste schema.jsonLd…
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` 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 ? (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(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 `<title>` when non-null; else use the normal title/`nameFa`.
|
||||||
|
2. Prefer `seoMetaDescription` for `<meta name="description">` 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**
|
**Checklist before shipping a page**
|
||||||
1. Unique `<title>` and meta description
|
1. Unique `<title>` and meta description (use `seoMeta*` when present)
|
||||||
2. One clear H1 + sensible H2 sections
|
2. One clear H1 + sensible H2 sections
|
||||||
3. All content images have alt text
|
3. All content images have alt text
|
||||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,18 @@
|
|||||||
"store_item"
|
"store_item"
|
||||||
],
|
],
|
||||||
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
|
"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" },
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "{ blog }"
|
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -427,6 +427,7 @@ model Category {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
nameFa String? @map("name_fa") @db.VarChar(255)
|
nameFa String? @map("name_fa") @db.VarChar(255)
|
||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
|
metadata Json @default("{}")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
|
|||||||
+87
-13
@@ -12,7 +12,16 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.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 { 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 { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +147,10 @@ export class BlogsService {
|
|||||||
status,
|
status,
|
||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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,
|
include: blogInclude,
|
||||||
});
|
});
|
||||||
@@ -218,10 +230,13 @@ export class BlogsService {
|
|||||||
nextContent.html = dto.mainTextHtml ?? '';
|
nextContent.html = dto.mainTextHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -374,10 +389,7 @@ export class BlogsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blog: await this.serializeBlog(blog, {
|
blog: await this.serializePublicBlog(host, business, blog),
|
||||||
includeComments: true,
|
|
||||||
approvedCommentsOnly: true,
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,10 +417,71 @@ export class BlogsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blog: await this.serializeBlog(blog, {
|
blog: await this.serializePublicBlog(host, business, blog),
|
||||||
includeComments: true,
|
};
|
||||||
approvedCommentsOnly: true,
|
}
|
||||||
}),
|
|
||||||
|
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,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
authorId: blog.author_id?.toString() ?? null,
|
authorId: blog.author_id?.toString() ?? null,
|
||||||
author: blog.author
|
author: blog.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListBlogsDto {
|
export class ListBlogsDto {
|
||||||
@@ -107,6 +108,16 @@ export class CreateBlogDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -152,6 +163,16 @@ export class UpdateBlogDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
sanitizeBusinessSettingsForClient,
|
sanitizeBusinessSettingsForClient,
|
||||||
toPrismaJson,
|
toPrismaJson,
|
||||||
} from './business-settings.util';
|
} from './business-settings.util';
|
||||||
|
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||||
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
import { UpdateBusinessSettingsDto } from './dto/update-business-settings.dto';
|
||||||
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
import { normalizeBusinessPrimaryColorId } from './business-primary-colors';
|
||||||
|
|
||||||
@@ -190,6 +191,17 @@ export class BusinessSettingsService {
|
|||||||
specialProductsSource:
|
specialProductsSource:
|
||||||
dto.website.specialProductsSource ??
|
dto.website.specialProductsSource ??
|
||||||
current.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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -111,6 +111,43 @@ export type WebsiteSettings = {
|
|||||||
specialProductsSource: SpecialProductsSource;
|
specialProductsSource: SpecialProductsSource;
|
||||||
/** Imported from GET https://{domain}/meshkee/sitemap-config.json */
|
/** Imported from GET https://{domain}/meshkee/sitemap-config.json */
|
||||||
sitemapConfig?: WebsiteSitemapConfig | null;
|
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 =
|
export type SitemapChangefreq =
|
||||||
@@ -322,6 +359,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
},
|
},
|
||||||
website: {
|
website: {
|
||||||
specialProductsSource: 'store_item',
|
specialProductsSource: 'store_item',
|
||||||
|
schema: { ...DEFAULT_WEBSITE_SCHEMA_SETTINGS },
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber: null,
|
senderNumber: null,
|
||||||
|
|||||||
@@ -33,11 +33,13 @@ import {
|
|||||||
StubGatewaySettings,
|
StubGatewaySettings,
|
||||||
WebsiteSitemapConfig,
|
WebsiteSitemapConfig,
|
||||||
ZarinpalGatewaySettings,
|
ZarinpalGatewaySettings,
|
||||||
|
DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
import {
|
import {
|
||||||
defaultOrderStepColor,
|
defaultOrderStepColor,
|
||||||
normalizeOrderStepColor,
|
normalizeOrderStepColor,
|
||||||
} from './order-step-colors';
|
} from './order-step-colors';
|
||||||
|
import { normalizeWebsiteSchemaSettings } from './website-schema.util';
|
||||||
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
@@ -441,6 +443,9 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
enabledModules,
|
enabledModules,
|
||||||
),
|
),
|
||||||
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
|
sitemapConfig: normalizeWebsiteSitemapConfig(website.sitemapConfig),
|
||||||
|
schema: normalizeWebsiteSchemaSettings(
|
||||||
|
isRecord(website) ? website.schema : DEFAULT_WEBSITE_SCHEMA_SETTINGS,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
senderNumber: normalizeSmsSenderNumber(sms.senderNumber),
|
||||||
@@ -495,6 +500,10 @@ export function mergeBusinessSettings(
|
|||||||
patch.website?.sitemapConfig !== undefined
|
patch.website?.sitemapConfig !== undefined
|
||||||
? patch.website.sitemapConfig
|
? patch.website.sitemapConfig
|
||||||
: current.website.sitemapConfig,
|
: current.website.sitemapConfig,
|
||||||
|
schema:
|
||||||
|
patch.website?.schema !== undefined
|
||||||
|
? patch.website.schema
|
||||||
|
: current.website.schema,
|
||||||
},
|
},
|
||||||
sms: {
|
sms: {
|
||||||
senderNumber:
|
senderNumber:
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
PAYMENT_GATEWAY_IDS,
|
PAYMENT_GATEWAY_IDS,
|
||||||
SPECIAL_PRODUCTS_SOURCE_IDS,
|
SPECIAL_PRODUCTS_SOURCE_IDS,
|
||||||
|
WEBSITE_SCHEMA_TYPE_IDS,
|
||||||
} from '../business-settings.types';
|
} from '../business-settings.types';
|
||||||
|
|
||||||
class BrandingSettingsDto {
|
class BrandingSettingsDto {
|
||||||
@@ -199,11 +200,49 @@ class ModulesSettingsDto {
|
|||||||
charts?: string[];
|
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 {
|
class WebsiteSettingsDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
|
@IsIn([...SPECIAL_PRODUCTS_SOURCE_IDS])
|
||||||
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
|
specialProductsSource?: (typeof SPECIAL_PRODUCTS_SOURCE_IDS)[number];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateNested()
|
||||||
|
@Type(() => WebsiteSchemaSettingsDto)
|
||||||
|
schema?: WebsiteSchemaSettingsDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateBusinessSettingsDto {
|
export class UpdateBusinessSettingsDto {
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown> {
|
||||||
|
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<string>();
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -6,16 +6,29 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
MediaEntityType,
|
MediaEntityType,
|
||||||
|
Prisma,
|
||||||
TranslationEntityType,
|
TranslationEntityType,
|
||||||
TranslationStatus,
|
TranslationStatus,
|
||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import {
|
||||||
|
applySeoMetaToMetadata,
|
||||||
|
normalizeSeoMetaInput,
|
||||||
|
readSeoMetaFromMetadata,
|
||||||
|
} from '../common/seo-meta.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SitemapService } from '../sitemap/sitemap.service';
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
import { CreateCategoryDto, ListCategoriesDto, UpdateCategoryDto } from './dto/category.dto';
|
||||||
|
|
||||||
|
function asRecord(value: Prisma.JsonValue): Record<string, unknown> {
|
||||||
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
function slugify(value: string): string {
|
function slugify(value: string): string {
|
||||||
return (
|
return (
|
||||||
value
|
value
|
||||||
@@ -157,6 +170,10 @@ export class CategoriesService {
|
|||||||
slug,
|
slug,
|
||||||
description: dto.description?.trim() || null,
|
description: dto.description?.trim() || null,
|
||||||
sortOrder: dto.sortOrder ?? 0,
|
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.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
...(dto.isActive !== undefined ? { isActive: dto.isActive } : {}),
|
||||||
...(parentId !== undefined ? { parentId } : {}),
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
...(dto.seoMetaTitle !== undefined || dto.seoMetaDescription !== undefined
|
||||||
|
? {
|
||||||
|
metadata: applySeoMetaToMetadata(
|
||||||
|
asRecord(existing.metadata),
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
) as Prisma.InputJsonValue,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
slug,
|
slug,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -395,6 +420,7 @@ export class CategoriesService {
|
|||||||
nameFa: string | null;
|
nameFa: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
metadata?: Prisma.JsonValue;
|
||||||
sortOrder: number;
|
sortOrder: number;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
@@ -412,6 +438,7 @@ export class CategoriesService {
|
|||||||
nameFa: category.nameFa,
|
nameFa: category.nameFa,
|
||||||
slug: category.slug,
|
slug: category.slug,
|
||||||
description: category.description,
|
description: category.description,
|
||||||
|
...readSeoMetaFromMetadata(asRecord(category.metadata ?? {})),
|
||||||
sortOrder: category.sortOrder,
|
sortOrder: category.sortOrder,
|
||||||
isActive: category.isActive,
|
isActive: category.isActive,
|
||||||
variationCount,
|
variationCount,
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { MediaEntityType } from '@prisma/client';
|
import { MediaEntityType } from '@prisma/client';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class ListCategoriesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -29,6 +38,16 @@ export class CreateCategoryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
@@ -61,6 +80,16 @@ export class UpdateCategoryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>): 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<SeoMetaFields> {
|
||||||
|
const out: Partial<SeoMetaFields> = {};
|
||||||
|
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<string, unknown>,
|
||||||
|
seo: Partial<SeoMetaFields>,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
+121
-114
@@ -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 { Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
@@ -769,7 +777,8 @@ export class CustomersService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Bulk SMS. Empty/omitted userIds → all enabled business customers.
|
* 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(
|
async sendBulkSms(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
@@ -863,139 +872,137 @@ export class CustomersService {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
message: 'SMS is disabled. Message was not sent.',
|
message: 'SMS is disabled. Message was not sent.',
|
||||||
recipientCount: recipients.length,
|
recipientCount: recipients.length,
|
||||||
|
sentCount: 0,
|
||||||
|
failedCount: 0,
|
||||||
|
failures: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const sample = await this.resolveOutboundSms(
|
const payloads: Array<{
|
||||||
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<{
|
|
||||||
cellNumber: string;
|
cellNumber: string;
|
||||||
message: string;
|
message: string;
|
||||||
source: string;
|
source: string;
|
||||||
cost: number;
|
cost: number;
|
||||||
|
templateId: string | null;
|
||||||
|
templateVerified: boolean | null;
|
||||||
}> = [];
|
}> = [];
|
||||||
|
|
||||||
if (!personalized) {
|
for (const recipient of recipients) {
|
||||||
charged = await this.smsBilling.charge(
|
const resolved = await this.resolveOutboundSms(
|
||||||
businessId,
|
businessId,
|
||||||
sample.message,
|
business.settings,
|
||||||
recipients.length,
|
dto.message,
|
||||||
'mass',
|
dto.templateId,
|
||||||
);
|
{
|
||||||
} else {
|
firstName: recipient.firstName,
|
||||||
personalizedPayloads = [];
|
lastName: recipient.lastName,
|
||||||
let totalCost = 0;
|
titleName: recipient.titleName,
|
||||||
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',
|
|
||||||
);
|
);
|
||||||
|
payloads.push({
|
||||||
|
cellNumber: recipient.cellNumber,
|
||||||
|
message: resolved.message,
|
||||||
|
source: resolved.source,
|
||||||
|
cost: this.smsBilling.costForMessage(resolved.message),
|
||||||
|
templateId: resolved.templateId,
|
||||||
|
templateVerified: resolved.templateVerified,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const serverIds: string[] = [];
|
||||||
if (!personalized) {
|
const failures: Array<{ cellNumber: string; error: string }> = [];
|
||||||
const result = await this.sms.sendBulkMessage(
|
let sentCount = 0;
|
||||||
recipients.map((r) => r.cellNumber),
|
let balanceExhausted = false;
|
||||||
sample.message,
|
|
||||||
{ source: sample.source },
|
for (const payload of payloads) {
|
||||||
);
|
if (balanceExhausted) {
|
||||||
return {
|
failures.push({
|
||||||
enabled: true,
|
cellNumber: payload.cellNumber,
|
||||||
message: 'Message sent successfully',
|
error: 'Insufficient SMS balance. Contact Meshkee to top up.',
|
||||||
recipientCount: result.recipientCount,
|
});
|
||||||
serverIds: result.serverIds,
|
continue;
|
||||||
source: sample.source,
|
|
||||||
templateId: sample.templateId,
|
|
||||||
templateVerified: sample.templateVerified,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serverIds: string[] = [];
|
let charged = 0;
|
||||||
for (let i = 0; i < personalizedPayloads.length; i++) {
|
try {
|
||||||
const payload = personalizedPayloads[i];
|
charged = await this.smsBilling.chargeAmount(
|
||||||
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(
|
|
||||||
businessId,
|
businessId,
|
||||||
charged,
|
payload.cost,
|
||||||
personalized ? personalizedPayloads.length : recipients.length,
|
1,
|
||||||
'mass',
|
'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 ||
|
try {
|
||||||
err instanceof ServiceUnavailableException
|
const result = await this.sms.sendMessage(
|
||||||
) {
|
payload.cellNumber,
|
||||||
throw err;
|
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';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListInstructionsDto {
|
export class ListInstructionsDto {
|
||||||
@@ -114,6 +115,16 @@ export class CreateInstructionDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -173,6 +184,16 @@ export class UpdateInstructionDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import {
|
import {
|
||||||
@@ -166,7 +171,10 @@ export class InstructionsService {
|
|||||||
status,
|
status,
|
||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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,
|
include: instructionInclude,
|
||||||
});
|
});
|
||||||
@@ -260,10 +268,13 @@ export class InstructionsService {
|
|||||||
nextContent.html = dto.descriptionHtml ?? '';
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -661,6 +672,7 @@ export class InstructionsService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
authorId: video.author_id?.toString() ?? null,
|
authorId: video.author_id?.toString() ?? null,
|
||||||
author: video.author
|
author: video.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListPortfoliosDto {
|
export class ListPortfoliosDto {
|
||||||
@@ -113,6 +114,16 @@ export class CreatePortfolioDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -174,6 +185,16 @@ export class UpdatePortfolioDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.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 { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { SitemapService } from '../sitemap/sitemap.service';
|
import { SitemapService } from '../sitemap/sitemap.service';
|
||||||
@@ -166,7 +171,10 @@ export class PortfoliosService {
|
|||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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,
|
include: portfolioInclude,
|
||||||
});
|
});
|
||||||
@@ -265,10 +273,13 @@ export class PortfoliosService {
|
|||||||
nextContent.html = dto.mainTextHtml ?? '';
|
nextContent.html = dto.mainTextHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -786,6 +797,7 @@ export class PortfoliosService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
titleImageUrl:
|
titleImageUrl:
|
||||||
portfolio.media?.publicUrl ??
|
portfolio.media?.publicUrl ??
|
||||||
galleryAttachments[0]?.media.publicUrl ??
|
galleryAttachments[0]?.media.publicUrl ??
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListProductsDto {
|
export class ListProductsDto {
|
||||||
@@ -112,6 +113,17 @@ export class CreateProductDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -162,6 +174,17 @@ export class UpdateProductDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -14,8 +14,17 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BrandsService } from '../brands/brands.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 { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.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 { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
import { CreateProductDto, ListProductsDto, ListPublicProductsDto, UpdateProductDto } from './dto/product.dto';
|
||||||
@@ -189,7 +198,7 @@ export class ProductsService {
|
|||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.serializePublicDetail(businessId, product);
|
return this.serializePublicDetail(host, businessId, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPublicById(host: string, productIdRaw: string) {
|
async getPublicById(host: string, productIdRaw: string) {
|
||||||
@@ -215,20 +224,80 @@ export class ProductsService {
|
|||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.serializePublicDetail(businessId, product);
|
return this.serializePublicDetail(host, businessId, product);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async serializePublicDetail(
|
private async serializePublicDetail(
|
||||||
|
host: string,
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>,
|
product: Prisma.ProductGetPayload<{ include: typeof productListInclude }>,
|
||||||
) {
|
) {
|
||||||
const [serializedList, relatedProducts] = await Promise.all([
|
const [serializedList, relatedProducts, business] = await Promise.all([
|
||||||
this.serializePublicListItems(businessId, [product]),
|
this.serializePublicListItems(businessId, [product]),
|
||||||
this.loadRelatedPublicProducts(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 {
|
return {
|
||||||
product: serializedList[0],
|
product: {
|
||||||
|
...productPayload,
|
||||||
|
schema: { jsonLd },
|
||||||
|
},
|
||||||
relatedProducts,
|
relatedProducts,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -296,7 +365,10 @@ export class ProductsService {
|
|||||||
featuredMediaId,
|
featuredMediaId,
|
||||||
brandId,
|
brandId,
|
||||||
publishedAt: status === ContentStatus.published ? new Date() : null,
|
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: {
|
include: {
|
||||||
featuredMedia: true,
|
featuredMedia: true,
|
||||||
@@ -379,10 +451,13 @@ export class ProductsService {
|
|||||||
nextContent.html = dto.descriptionHtml ?? '';
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -640,6 +715,7 @@ export class ProductsService {
|
|||||||
tags: Array.isArray(metadata.tags)
|
tags: Array.isArray(metadata.tags)
|
||||||
? (metadata.tags as string[])
|
? (metadata.tags as string[])
|
||||||
: [],
|
: [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
thumbnailUrl,
|
thumbnailUrl,
|
||||||
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
thumbnailMediaId: product.featuredMediaId?.toString() ?? null,
|
||||||
image,
|
image,
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export class TenantService {
|
|||||||
enabledModules: settings.modules.enabled,
|
enabledModules: settings.modules.enabled,
|
||||||
homeCharts: settings.modules.charts,
|
homeCharts: settings.modules.charts,
|
||||||
specialProductsSource: settings.website.specialProductsSource,
|
specialProductsSource: settings.website.specialProductsSource,
|
||||||
|
schema: {
|
||||||
|
enabled: settings.website.schema.enabled,
|
||||||
|
type: settings.website.schema.type,
|
||||||
|
},
|
||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||||
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
|
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
@@ -166,6 +167,16 @@ export class CreateUserProductDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
galleryMediaIds?: string[];
|
galleryMediaIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateUserProductDto extends CreateUserProductDto {}
|
export class UpdateUserProductDto extends CreateUserProductDto {}
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { CategoryTechnicalFormService } from '../categories/category-technical-form.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 { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
@@ -337,9 +342,12 @@ export class UserProductsService {
|
|||||||
const content: Prisma.InputJsonValue = {
|
const content: Prisma.InputJsonValue = {
|
||||||
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
||||||
};
|
};
|
||||||
const metadata: Prisma.InputJsonValue = {
|
const metadata = applySeoMetaToMetadata(
|
||||||
priceByExpert: dto.priceByExpert === true,
|
{
|
||||||
};
|
priceByExpert: dto.priceByExpert === true,
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
) as Prisma.InputJsonValue;
|
||||||
|
|
||||||
const created = await this.prisma.$transaction(async (tx) => {
|
const created = await this.prisma.$transaction(async (tx) => {
|
||||||
const product = await tx.userProduct.create({
|
const product = await tx.userProduct.create({
|
||||||
@@ -522,10 +530,13 @@ export class UserProductsService {
|
|||||||
const content: Prisma.InputJsonValue = {
|
const content: Prisma.InputJsonValue = {
|
||||||
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}),
|
||||||
};
|
};
|
||||||
const metadata: Prisma.InputJsonValue = {
|
const metadata = applySeoMetaToMetadata(
|
||||||
...existingMetadata,
|
{
|
||||||
priceByExpert: dto.priceByExpert === true,
|
...existingMetadata,
|
||||||
};
|
priceByExpert: dto.priceByExpert === true,
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
) as Prisma.InputJsonValue;
|
||||||
|
|
||||||
let slug = existing.slug;
|
let slug = existing.slug;
|
||||||
if (titleFa !== existing.title) {
|
if (titleFa !== existing.title) {
|
||||||
@@ -1269,6 +1280,7 @@ export class UserProductsService {
|
|||||||
priceCurrency: product.priceCurrency,
|
priceCurrency: product.priceCurrency,
|
||||||
priceByExpert,
|
priceByExpert,
|
||||||
promoted,
|
promoted,
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
status: product.status,
|
status: product.status,
|
||||||
condition: product.condition,
|
condition: product.condition,
|
||||||
deliveryNote: product.deliveryNote,
|
deliveryNote: product.deliveryNote,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListVideosDto {
|
export class ListVideosDto {
|
||||||
@@ -120,6 +121,16 @@ export class CreateVideoDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -179,6 +190,16 @@ export class UpdateVideoDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import {
|
import {
|
||||||
@@ -153,7 +158,10 @@ export class VideosService {
|
|||||||
status,
|
status,
|
||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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,
|
include: videoInclude,
|
||||||
});
|
});
|
||||||
@@ -247,10 +255,13 @@ export class VideosService {
|
|||||||
nextContent.html = dto.descriptionHtml ?? '';
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -651,6 +662,7 @@ export class VideosService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
authorId: video.author_id?.toString() ?? null,
|
authorId: video.author_id?.toString() ?? null,
|
||||||
author: video.author
|
author: video.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -319,11 +319,73 @@ These are **required on every crawlable page** (home, listing, product/blog/port
|
|||||||
**Open Graph (recommended)**
|
**Open Graph (recommended)**
|
||||||
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
- 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
|
||||||
|
<script type="application/ld+json">
|
||||||
|
…paste schema.jsonLd…
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` 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 ? (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(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 `<title>` when non-null; else use the normal title/`nameFa`.
|
||||||
|
2. Prefer `seoMetaDescription` for `<meta name="description">` 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**
|
**Checklist before shipping a page**
|
||||||
1. Unique `<title>` and meta description
|
1. Unique `<title>` and meta description (use `seoMeta*` when present)
|
||||||
2. One clear H1 + sensible H2 sections
|
2. One clear H1 + sensible H2 sections
|
||||||
3. All content images have alt text
|
3. All content images have alt text
|
||||||
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
|
|||||||
@@ -195,6 +195,18 @@
|
|||||||
"store_item"
|
"store_item"
|
||||||
],
|
],
|
||||||
"description": "Where homepage special-product carousels read from. Defaults to store_item when the store module is enabled, otherwise product."
|
"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" },
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"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": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "{ blog }"
|
"description": "{ blog }. `blog.schema.jsonLd` is BlogPosting Schema.org JSON-LD when website structured data is enabled (null otherwise)."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
normalizePhoneNumbers,
|
normalizePhoneNumbers,
|
||||||
normalizeSocialMedia,
|
normalizeSocialMedia,
|
||||||
} from '../business-profile/business-profile.util';
|
} 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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
|
|
||||||
@@ -16,6 +18,7 @@ export class WebsiteBusinessInfoService {
|
|||||||
|
|
||||||
async getPublic(host: string) {
|
async getPublic(host: string) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
|
||||||
const record = await this.prisma.business.findUnique({
|
const record = await this.prisma.business.findUnique({
|
||||||
where: { id: business.id },
|
where: { id: business.id },
|
||||||
@@ -31,27 +34,61 @@ export class WebsiteBusinessInfoService {
|
|||||||
throw new NotFoundException('Business not found');
|
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 {
|
return {
|
||||||
id: record.id.toString(),
|
id: record.id.toString(),
|
||||||
name: record.name,
|
name: record.name,
|
||||||
nameFa: record.nameFa ?? '',
|
nameFa: record.nameFa ?? '',
|
||||||
about: record.about ?? '',
|
about: record.about ?? '',
|
||||||
vision: record.vision ?? '',
|
vision: record.vision ?? '',
|
||||||
logoUrl: record.logoMedia?.publicUrl ?? null,
|
logoUrl,
|
||||||
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
|
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
||||||
emails: normalizeEmails(record.emails),
|
emails,
|
||||||
phoneNumbers: normalizePhoneNumbers(record.phoneNumbers),
|
phoneNumbers,
|
||||||
socialMedia: normalizeSocialMedia(record.socialMedia),
|
socialMedia,
|
||||||
addresses: record.addresses.map((address) => ({
|
addresses,
|
||||||
id: address.id.toString(),
|
schema: {
|
||||||
province: address.province,
|
enabled: schemaSettings.enabled,
|
||||||
city: address.city,
|
type: schemaSettings.type,
|
||||||
address: address.address,
|
name: schemaSettings.name,
|
||||||
postalCode: address.postalCode,
|
nameFa: schemaSettings.nameFa,
|
||||||
landline: address.landline,
|
description: schemaSettings.description,
|
||||||
})),
|
priceRange: schemaSettings.priceRange,
|
||||||
|
sameAs: schemaSettings.sameAs,
|
||||||
|
jsonLd,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ export class CreateWorkshopDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -182,6 +192,16 @@ export class UpdateWorkshopDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ import {
|
|||||||
} from '@prisma/client';
|
} from '@prisma/client';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import {
|
||||||
|
applySeoMetaToMetadata,
|
||||||
|
normalizeSeoMetaInput,
|
||||||
|
readSeoMetaFromMetadata,
|
||||||
|
} from '../common/seo-meta.util';
|
||||||
import { MediaService } from '../media/media.service';
|
import { MediaService } from '../media/media.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
@@ -158,7 +163,10 @@ export class WorkshopsService {
|
|||||||
status,
|
status,
|
||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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_date: this.parseEventDate(dto.eventDate) ?? null,
|
||||||
event_time: dto.eventTime?.trim() || null,
|
event_time: dto.eventTime?.trim() || null,
|
||||||
duration: dto.duration?.trim() || null,
|
duration: dto.duration?.trim() || null,
|
||||||
@@ -262,10 +270,13 @@ export class WorkshopsService {
|
|||||||
nextContent.html = dto.descriptionHtml ?? '';
|
nextContent.html = dto.descriptionHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -801,6 +812,7 @@ export class WorkshopsService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
authorId: workshop.author_id?.toString() ?? null,
|
authorId: workshop.author_id?.toString() ?? null,
|
||||||
author: workshop.author
|
author: workshop.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
Reference in New Issue
Block a user