(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isSyncingSitemap, setIsSyncingSitemap] = useState(false)
@@ -69,6 +72,7 @@ export function WebsiteSettingsPage() {
defaultSpecialProductsSource(data.settings.modules?.enabled),
)
setSitemapConfig(data.settings.website?.sitemapConfig ?? null)
+ setSchema(data.settings.website?.schema ?? null)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
@@ -92,6 +96,9 @@ export function WebsiteSettingsPage() {
try {
const data = await updateWebsiteSettings({ specialProductsSource: next })
setSource(data.settings.website?.specialProductsSource ?? next)
+ if (data.settings.website?.schema) {
+ setSchema(data.settings.website.schema)
+ }
showToast(t('website.settings.saved'), 'success')
} catch (err) {
setSource(previous)
@@ -222,6 +229,14 @@ export function WebsiteSettingsPage() {
)}
+ {!isLoading ? (
+
+ ) : null}
+
{storeModuleEnabled ? (
diff --git a/apps/business/src/services/blogCategoryService.ts b/apps/business/src/services/blogCategoryService.ts
index 975570d..e215369 100644
--- a/apps/business/src/services/blogCategoryService.ts
+++ b/apps/business/src/services/blogCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'blog' as const
@@ -61,6 +62,7 @@ export function toBlogCategoryFormPayload(data: CategoryFormData): CreateCategor
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapBlogCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
diff --git a/apps/business/src/services/blogService.ts b/apps/business/src/services/blogService.ts
index 158cd4a..67adab7 100644
--- a/apps/business/src/services/blogService.ts
+++ b/apps/business/src/services/blogService.ts
@@ -17,6 +17,8 @@ export interface BlogApi {
categoryId: string | null
categoryName: string
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
titleImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -60,6 +62,8 @@ export interface CreateBlogPayload {
status?: BlogStatus
featuredMediaId?: string
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export interface UpdateBlogPayload {
@@ -71,6 +75,8 @@ export interface UpdateBlogPayload {
status?: BlogStatus
featuredMediaId?: string | null
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export function resolveBlogTitleImageUrl(blog: BlogApi): string | null {
@@ -155,6 +161,8 @@ export function mapBlogApiToFormState(blog: BlogApi) {
mainTextHtml: blog.mainTextHtml,
categoryId: blog.categoryId ?? '',
tags: blog.tags,
+ seoMetaTitle: blog.seoMetaTitle ?? '',
+ seoMetaDescription: blog.seoMetaDescription ?? '',
titleImage: resolveBlogTitleImageUrl(blog),
featuredMediaId: blog.featuredMediaId,
}
diff --git a/apps/business/src/services/brandService.ts b/apps/business/src/services/brandService.ts
index b713955..76f96cd 100644
--- a/apps/business/src/services/brandService.ts
+++ b/apps/business/src/services/brandService.ts
@@ -106,6 +106,8 @@ export function mapBrandToSelectOption(brand: BrandApi): FlatCategory {
nameEn: brand.nameEn,
nameFa: brand.nameFa ?? '',
description: brand.about ?? '',
+ seoMetaTitle: '',
+ seoMetaDescription: '',
parentId: null,
variationCount: 0,
depth: 0,
diff --git a/apps/business/src/services/customerService.ts b/apps/business/src/services/customerService.ts
index c6d57f1..d1aac29 100644
--- a/apps/business/src/services/customerService.ts
+++ b/apps/business/src/services/customerService.ts
@@ -189,7 +189,10 @@ export interface SendCustomersBulkSmsResult {
enabled: boolean
message: string
recipientCount: number
+ sentCount?: number
+ failedCount?: number
serverIds?: string[]
+ failures?: Array<{ cellNumber: string; error: string }>
source?: string
}
diff --git a/apps/business/src/services/instructionCategoryService.ts b/apps/business/src/services/instructionCategoryService.ts
index a1df6d3..bfe1c4c 100644
--- a/apps/business/src/services/instructionCategoryService.ts
+++ b/apps/business/src/services/instructionCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'instruction' as const
@@ -61,6 +62,7 @@ export function toInstructionCategoryFormPayload(data: CategoryFormData): Create
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapInstructionCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
diff --git a/apps/business/src/services/instructionService.ts b/apps/business/src/services/instructionService.ts
index 1dec874..e1648f5 100644
--- a/apps/business/src/services/instructionService.ts
+++ b/apps/business/src/services/instructionService.ts
@@ -25,6 +25,8 @@ export interface InstructionApi {
categoryId: string | null
categoryName: string
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateInstructionPayload {
status?: InstructionStatus
featuredMediaId?: string
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export interface UpdateInstructionPayload {
@@ -83,6 +87,8 @@ export interface UpdateInstructionPayload {
status?: InstructionStatus
featuredMediaId?: string | null
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export function resolveInstructionTitle(
@@ -170,6 +176,8 @@ export function mapInstructionApiToFormState(instruction: InstructionApi) {
embedCode: instruction.embedCode,
categoryId: instruction.categoryId ?? '',
tags: instruction.tags,
+ seoMetaTitle: instruction.seoMetaTitle ?? '',
+ seoMetaDescription: instruction.seoMetaDescription ?? '',
coverImage: instruction.coverImageUrl,
featuredMediaId: instruction.featuredMediaId,
}
diff --git a/apps/business/src/services/portfolioCategoryService.ts b/apps/business/src/services/portfolioCategoryService.ts
index 505d72d..08e6675 100644
--- a/apps/business/src/services/portfolioCategoryService.ts
+++ b/apps/business/src/services/portfolioCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'portfolio' as const
@@ -61,6 +62,7 @@ export function toPortfolioCategoryFormPayload(data: CategoryFormData): CreateCa
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapPortfolioCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
diff --git a/apps/business/src/services/portfolioService.ts b/apps/business/src/services/portfolioService.ts
index a83e2d9..565fc6e 100644
--- a/apps/business/src/services/portfolioService.ts
+++ b/apps/business/src/services/portfolioService.ts
@@ -19,6 +19,8 @@ export interface PortfolioApi {
categoryId: string | null
categoryName: string
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
titleImageUrl: string | null
featuredMediaId: string | null
gallery: PortfolioGalleryItem[]
@@ -66,6 +68,8 @@ export interface CreatePortfolioPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
sortOrder?: number
}
@@ -82,6 +86,8 @@ export interface UpdatePortfolioPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
sortOrder?: number
/** Pin to absolute top of the business portfolio list. */
moveToTop?: boolean
@@ -171,6 +177,8 @@ export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
projectUrl: portfolio.projectUrl?.trim() || '',
categoryId: portfolio.categoryId ?? '',
tags: portfolio.tags,
+ seoMetaTitle: portfolio.seoMetaTitle ?? '',
+ seoMetaDescription: portfolio.seoMetaDescription ?? '',
titleImage: resolvePortfolioTitleImageUrl(portfolio),
featuredMediaId: portfolio.featuredMediaId,
images: portfolio.gallery.map((item) => item.url),
diff --git a/apps/business/src/services/productCategoryService.ts b/apps/business/src/services/productCategoryService.ts
index 1830c75..3a81b59 100644
--- a/apps/business/src/services/productCategoryService.ts
+++ b/apps/business/src/services/productCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'product' as const
@@ -75,6 +76,7 @@ export function toCategoryFormPayload(data: CategoryFormData): CreateCategoryPay
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -84,6 +86,8 @@ export function mapProductCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
translationCount: category.translationCount ?? 0,
diff --git a/apps/business/src/services/productService.ts b/apps/business/src/services/productService.ts
index ab295ab..1d8663f 100644
--- a/apps/business/src/services/productService.ts
+++ b/apps/business/src/services/productService.ts
@@ -27,6 +27,8 @@ export interface ProductApi {
imageUrl: string | null
} | null
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
thumbnailUrl: string | null
thumbnailMediaId: string | null
image: string
@@ -58,6 +60,8 @@ export interface CreateProductPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export interface UpdateProductPayload {
@@ -71,6 +75,8 @@ export interface UpdateProductPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
function businessPath(suffix = '') {
@@ -145,6 +151,8 @@ export function mapProductApiToFormState(product: ProductApi) {
thumbnail: product.thumbnailUrl ?? product.thumbnail ?? null,
images: product.images.map((item) => item.url),
tags: product.tags,
+ seoMetaTitle: product.seoMetaTitle ?? '',
+ seoMetaDescription: product.seoMetaDescription ?? '',
thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds,
}
diff --git a/apps/business/src/services/settingsService.ts b/apps/business/src/services/settingsService.ts
index 2142855..0af4518 100644
--- a/apps/business/src/services/settingsService.ts
+++ b/apps/business/src/services/settingsService.ts
@@ -91,6 +91,18 @@ export interface ModulesSettings {
export type SpecialProductsSource = 'product' | 'store_item'
+export type WebsiteSchemaType = 'Organization' | 'LocalBusiness'
+
+export interface WebsiteSchemaSettings {
+ enabled: boolean
+ type: WebsiteSchemaType
+ name: string | null
+ nameFa: string | null
+ description: string | null
+ priceRange: string | null
+ sameAs: string[]
+}
+
export interface WebsiteSitemapConfig {
baseUrl?: string | null
staticPages?: Array<{
@@ -105,6 +117,7 @@ export interface WebsiteSitemapConfig {
export interface WebsiteSettings {
specialProductsSource: SpecialProductsSource
sitemapConfig?: WebsiteSitemapConfig | null
+ schema?: WebsiteSchemaSettings
}
export interface SmsSettings {
diff --git a/apps/business/src/services/userProductsService.ts b/apps/business/src/services/userProductsService.ts
index 663abd0..e7f484f 100644
--- a/apps/business/src/services/userProductsService.ts
+++ b/apps/business/src/services/userProductsService.ts
@@ -61,6 +61,8 @@ export interface CreateUserProductInput {
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export type UpdateUserProductInput = CreateUserProductInput
@@ -77,6 +79,8 @@ export interface UserProductDetail extends UserProductListItem {
technicalNotes?: string | null
description?: string | null
titleEn?: string | null
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
priceCurrency?: string | null
priceByExpert?: boolean
promoted?: boolean
diff --git a/apps/business/src/services/videoCategoryService.ts b/apps/business/src/services/videoCategoryService.ts
index 3560050..469dab9 100644
--- a/apps/business/src/services/videoCategoryService.ts
+++ b/apps/business/src/services/videoCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'video' as const
@@ -61,6 +62,7 @@ export function toVideoCategoryFormPayload(data: CategoryFormData): CreateCatego
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapVideoCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
diff --git a/apps/business/src/services/videoService.ts b/apps/business/src/services/videoService.ts
index 0f7d1bb..4acd6d0 100644
--- a/apps/business/src/services/videoService.ts
+++ b/apps/business/src/services/videoService.ts
@@ -25,6 +25,8 @@ export interface VideoApi {
categoryId: string | null
categoryName: string
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateVideoPayload {
status?: VideoStatus
featuredMediaId?: string
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export interface UpdateVideoPayload {
@@ -83,6 +87,8 @@ export interface UpdateVideoPayload {
status?: VideoStatus
featuredMediaId?: string | null
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
}
export function resolveVideoTitle(
@@ -170,6 +176,8 @@ export function mapVideoApiToFormState(video: VideoApi) {
embedCode: video.embedCode,
categoryId: video.categoryId ?? '',
tags: video.tags,
+ seoMetaTitle: video.seoMetaTitle ?? '',
+ seoMetaDescription: video.seoMetaDescription ?? '',
coverImage: video.coverImageUrl,
featuredMediaId: video.featuredMediaId,
}
diff --git a/apps/business/src/services/workshopCategoryService.ts b/apps/business/src/services/workshopCategoryService.ts
index 1127eb6..bf996c7 100644
--- a/apps/business/src/services/workshopCategoryService.ts
+++ b/apps/business/src/services/workshopCategoryService.ts
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
+import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'workshop' as const
@@ -61,6 +62,7 @@ export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCat
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
+ ...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapWorkshopCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
+ seoMetaTitle: category.seoMetaTitle ?? '',
+ seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
diff --git a/apps/business/src/services/workshopService.ts b/apps/business/src/services/workshopService.ts
index 71826f6..d230791 100644
--- a/apps/business/src/services/workshopService.ts
+++ b/apps/business/src/services/workshopService.ts
@@ -24,6 +24,8 @@ export interface WorkshopApi {
categoryId: string | null
categoryName: string
tags: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
gallery: WorkshopGalleryItem[]
@@ -93,6 +95,8 @@ export interface CreateWorkshopPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
eventDate?: string | null
eventTime?: string | null
duration?: string | null
@@ -108,6 +112,8 @@ export interface UpdateWorkshopPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
eventDate?: string | null
eventTime?: string | null
duration?: string | null
@@ -291,6 +297,8 @@ export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
categoryId: workshop.categoryId ?? '',
tags: Array.isArray(workshop.tags) ? workshop.tags : [],
+ seoMetaTitle: workshop.seoMetaTitle ?? '',
+ seoMetaDescription: workshop.seoMetaDescription ?? '',
eventDate: normalizeGregorianDate(workshop.eventDate),
eventTime: normalizeTimeInput(workshop.eventTime),
duration:
diff --git a/apps/business/src/types/category.ts b/apps/business/src/types/category.ts
index d72af09..7db9b4e 100644
--- a/apps/business/src/types/category.ts
+++ b/apps/business/src/types/category.ts
@@ -3,6 +3,8 @@ export interface Category {
nameEn: string
nameFa: string
description: string
+ seoMetaTitle: string
+ seoMetaDescription: string
parentId: string | null
variationCount: number
translationCount?: number
@@ -13,6 +15,8 @@ export interface CategoryFormData {
nameFa: string
nameEn: string
description: string
+ seoMetaTitle: string
+ seoMetaDescription: string
}
export interface FlatCategory extends Category {
@@ -28,6 +32,8 @@ export interface ProductCategory {
nameFa: string | null
slug: string
description: string | null
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
sortOrder: number
isActive: boolean
variationCount?: number
@@ -45,6 +51,8 @@ export interface CreateCategoryPayload {
name: string
nameFa?: string
description?: string
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
slug?: string
sortOrder?: number
}
@@ -54,6 +62,8 @@ export interface UpdateCategoryPayload {
name?: string
nameFa?: string | null
description?: string | null
+ seoMetaTitle?: string | null
+ seoMetaDescription?: string | null
slug?: string
sortOrder?: number
isActive?: boolean
diff --git a/apps/business/src/utils/seoMeta.ts b/apps/business/src/utils/seoMeta.ts
new file mode 100644
index 0000000..1507b3b
--- /dev/null
+++ b/apps/business/src/utils/seoMeta.ts
@@ -0,0 +1,9 @@
+/** Normalize optional SEO overrides for create/update API payloads. */
+export function seoMetaPayload(seoMetaTitle: string, seoMetaDescription: string) {
+ const title = seoMetaTitle.trim()
+ const description = seoMetaDescription.trim()
+ return {
+ seoMetaTitle: title || null,
+ seoMetaDescription: description || null,
+ }
+}
diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md
index f1d07b4..6d58da5 100644
--- a/docs/PROJECT_CONTEXT.md
+++ b/docs/PROJECT_CONTEXT.md
@@ -217,7 +217,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/website` | Website hub | Placeholder |
| `/website/static-images` | Named website image slots, grouped by page | Yes |
| `/website/special-items` | Special item carousels | Yes |
-| `/website/settings` | Website settings (special products source: product vs store item) | Yes |
+| `/website/settings` | Website settings (special products source; sitemap rebuild; site-wide Schema.org Organization/LocalBusiness; e-payment when store module on) | Yes |
| `/website/contact` | Contact us submissions list | Yes |
| `/website/subscriptions` | Subscriptions | Placeholder |
| `/website/faq` | FAQ | Placeholder |
@@ -277,6 +277,8 @@ Products accept optional `brandId` on create/update.
### Business settings
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website`
- `website.specialProductsSource`: `product` \| `store_item`. Unset → `store_item` if the store module is enabled, otherwise `product`.
+- `website.schema`: site-wide Schema.org settings (`enabled`, `type` Organization|LocalBusiness, optional name/description/priceRange/sameAs). Public `GET /tenants/:host/website/business-info` returns `schema.jsonLd` ready for `