Add website schema settings, SEO meta overrides, and resilient bulk SMS.

Business admins can configure site-wide Schema.org and optional per-item SEO title/description; mass SMS continues on failures with an end report.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 23:30:23 +03:30
co-authored by Cursor
parent 15aad9f9d8
commit c2ede07e73
37 changed files with 1217 additions and 89 deletions
@@ -6,6 +6,7 @@ import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category' import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect' import { SearchableSelect } from './SearchableSelect'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css' import styles from './CategoryModal.module.css'
interface CategoryModalProps { interface CategoryModalProps {
@@ -41,6 +42,8 @@ export function CategoryModal({
const [nameEn, setNameEn] = useState('') const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('') const [nameFa, setNameFa] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
useEffect(() => { useEffect(() => {
if (open) { if (open) {
@@ -50,6 +53,8 @@ export function CategoryModal({
setNameEn(editingCategory?.nameEn ?? '') setNameEn(editingCategory?.nameEn ?? '')
setNameFa(editingCategory?.nameFa ?? '') setNameFa(editingCategory?.nameFa ?? '')
setDescription(editingCategory?.description ?? '') setDescription(editingCategory?.description ?? '')
setSeoMetaTitle(editingCategory?.seoMetaTitle ?? '')
setSeoMetaDescription(editingCategory?.seoMetaDescription ?? '')
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -78,6 +83,8 @@ export function CategoryModal({
nameFa: nameFa.trim(), nameFa: nameFa.trim(),
nameEn: nameEn.trim(), nameEn: nameEn.trim(),
description: description.trim(), description: description.trim(),
seoMetaTitle,
seoMetaDescription,
}) })
} }
@@ -170,6 +177,19 @@ export function CategoryModal({
/> />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
<div className={styles.actions}> <div className={styles.actions}>
<button <button
type="button" type="button"
@@ -9,6 +9,7 @@ import {
sendCustomerSms, sendCustomerSms,
sendCustomersBulkSms, sendCustomersBulkSms,
type BusinessCustomerListItem, type BusinessCustomerListItem,
type SendCustomersBulkSmsResult,
} from '../services/customerService' } from '../services/customerService'
import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService' import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService'
import modalStyles from './VariationsModal.module.css' import modalStyles from './VariationsModal.module.css'
@@ -23,7 +24,13 @@ interface SendCustomerSmsModalProps {
open: boolean open: boolean
target: SmsComposeTarget | null target: SmsComposeTarget | null
onClose: () => void onClose: () => void
onSent: (info: { mode: SmsComposeTarget['mode']; count: number; phone?: string }) => void onSent: (info: {
mode: SmsComposeTarget['mode']
count: number
phone?: string
failedCount?: number
totalCount?: number
}) => void
} }
const ANIMATION_MS = 220 const ANIMATION_MS = 220
@@ -47,6 +54,7 @@ export function SendCustomerSmsModal({
const [templatesLoading, setTemplatesLoading] = useState(false) const [templatesLoading, setTemplatesLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [bulkReport, setBulkReport] = useState<SendCustomersBulkSmsResult | null>(null)
const messageRef = useRef<HTMLTextAreaElement>(null) const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => { useEffect(() => {
@@ -56,6 +64,7 @@ export function SendCustomerSmsModal({
setMessage('') setMessage('')
setTemplateId('') setTemplateId('')
setError('') setError('')
setBulkReport(null)
} else if (mounted) { } else if (mounted) {
setClosing(true) setClosing(true)
const timer = setTimeout(() => { const timer = setTimeout(() => {
@@ -98,6 +107,7 @@ export function SendCustomerSmsModal({
const selectedTemplate = templates.find((item) => item.id === templateId) ?? null const selectedTemplate = templates.find((item) => item.id === templateId) ?? null
const trimmed = message.trim() const trimmed = message.trim()
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
const showingReport = bulkReport !== null
function applyTemplate(nextId: string) { function applyTemplate(nextId: string) {
setTemplateId(nextId) setTemplateId(nextId)
@@ -125,6 +135,7 @@ export function SendCustomerSmsModal({
if (!target || !canSend) return if (!target || !canSend) return
setIsSubmitting(true) setIsSubmitting(true)
setError('') setError('')
setBulkReport(null)
try { try {
const tplId = templateId || null const tplId = templateId || null
if (target.mode === 'single') { if (target.mode === 'single') {
@@ -138,6 +149,7 @@ export function SendCustomerSmsModal({
count: 1, count: 1,
phone: formatCellForDisplay(target.customer.cellNumber), phone: formatCellForDisplay(target.customer.cellNumber),
}) })
onClose()
} else { } else {
const userIds = target.mode === 'selected' ? target.userIds : undefined const userIds = target.mode === 'selected' ? target.userIds : undefined
const result = await sendCustomersBulkSms(trimmed, userIds, tplId) const result = await sendCustomersBulkSms(trimmed, userIds, tplId)
@@ -145,9 +157,27 @@ export function SendCustomerSmsModal({
setError(result.message || t('customers.smsModal.errorDisabled')) setError(result.message || t('customers.smsModal.errorDisabled'))
return return
} }
onSent({ mode: target.mode, count: result.recipientCount })
const sentCount = result.sentCount ?? result.recipientCount
const failedCount = result.failedCount ?? result.failures?.length ?? 0
const totalCount = result.recipientCount
if (sentCount > 0) {
onSent({
mode: target.mode,
count: sentCount,
failedCount,
totalCount,
})
} }
if (failedCount > 0) {
setBulkReport(result)
return
}
onClose() onClose()
}
} catch (err) { } catch (err) {
setError(err instanceof ApiError ? err.message : t('customers.smsModal.error')) setError(err instanceof ApiError ? err.message : t('customers.smsModal.error'))
} finally { } finally {
@@ -156,7 +186,9 @@ export function SendCustomerSmsModal({
} }
let subtitle: string let subtitle: string
if (target.mode === 'single') { if (showingReport) {
subtitle = t('customers.smsModal.reportTitle')
} else if (target.mode === 'single') {
const phone = formatCellForDisplay(target.customer.cellNumber) const phone = formatCellForDisplay(target.customer.cellNumber)
const name = [target.customer.firstName, target.customer.lastName] const name = [target.customer.firstName, target.customer.lastName]
.filter(Boolean) .filter(Boolean)
@@ -169,6 +201,20 @@ export function SendCustomerSmsModal({
subtitle = t('customers.smsModal.subtitleAll') subtitle = t('customers.smsModal.subtitleAll')
} }
const reportSent = bulkReport?.sentCount ?? 0
const reportFailed = bulkReport?.failedCount ?? bulkReport?.failures?.length ?? 0
const reportTotal = bulkReport?.recipientCount ?? 0
const reportSummary =
reportFailed === 0
? t('customers.smsModal.reportAllSent', { count: reportSent })
: reportSent === 0
? t('customers.smsModal.reportNone', { failed: reportFailed })
: t('customers.smsModal.reportPartial', {
sent: reportSent,
total: reportTotal,
failed: reportFailed,
})
return createPortal( return createPortal(
<div <div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`} className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -202,6 +248,35 @@ export function SendCustomerSmsModal({
</div> </div>
<div className={modalStyles.body}> <div className={modalStyles.body}>
{showingReport ? (
<div className={modalStyles.smsReport}>
<p
className={`${modalStyles.smsReportSummary} ${
reportFailed === 0 ? modalStyles.smsReportSummaryOk : ''
}`}
>
{reportSummary}
</p>
{reportFailed > 0 && (bulkReport?.failures?.length ?? 0) > 0 ? (
<ul className={modalStyles.smsReportFailures}>
{bulkReport!.failures!.map((item) => (
<li key={`${item.cellNumber}-${item.error}`} className={modalStyles.smsReportFailure}>
<span className={modalStyles.smsReportPhone}>
{formatCellForDisplay(item.cellNumber)}
</span>
<span className={modalStyles.smsReportError}>{item.error}</span>
</li>
))}
</ul>
) : null}
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.submitBtn} onClick={onClose}>
{t('customers.smsModal.reportDone')}
</button>
</div>
</div>
) : (
<>
{error ? <p className={modalStyles.errorText}>{error}</p> : null} {error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.field}> <div className={modalStyles.field}>
@@ -290,6 +365,8 @@ export function SendCustomerSmsModal({
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')} {isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button> </button>
</div> </div>
</>
)}
</div> </div>
</div> </div>
</div>, </div>,
@@ -0,0 +1,64 @@
.block {
display: flex;
flex-direction: column;
gap: 6px;
grid-column: 1 / -1;
}
.hint {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.input,
.textarea {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.textarea {
min-height: 78px;
resize: vertical;
}
.input:focus,
.textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.input:disabled,
.textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.counter {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.counter:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,69 @@
import styles from './SeoMetaFields.module.css'
const TITLE_MAX = 70
const DESCRIPTION_MAX = 160
interface SeoMetaFieldsProps {
seoMetaTitle: string
seoMetaDescription: string
onTitleChange: (value: string) => void
onDescriptionChange: (value: string) => void
disabled?: boolean
titleLabel: string
descriptionLabel: string
titlePlaceholder: string
descriptionPlaceholder: string
hint: string
}
/** Optional SEO overrides shared by content/category forms. */
export function SeoMetaFields({
seoMetaTitle,
seoMetaDescription,
onTitleChange,
onDescriptionChange,
disabled = false,
titleLabel,
descriptionLabel,
titlePlaceholder,
descriptionPlaceholder,
hint,
}: SeoMetaFieldsProps) {
return (
<div className={styles.block}>
<p className={styles.hint}>{hint}</p>
<label className={styles.label} htmlFor="seo-meta-title">
{titleLabel}
</label>
<input
id="seo-meta-title"
className={styles.input}
type="text"
value={seoMetaTitle}
disabled={disabled}
maxLength={TITLE_MAX}
placeholder={titlePlaceholder}
onChange={(e) => onTitleChange(e.target.value.slice(0, TITLE_MAX))}
/>
<p className={styles.counter}>
{seoMetaTitle.length} / {TITLE_MAX}
</p>
<label className={styles.label} htmlFor="seo-meta-description">
{descriptionLabel}
</label>
<textarea
id="seo-meta-description"
className={styles.textarea}
rows={3}
value={seoMetaDescription}
disabled={disabled}
maxLength={DESCRIPTION_MAX}
placeholder={descriptionPlaceholder}
onChange={(e) => onDescriptionChange(e.target.value.slice(0, DESCRIPTION_MAX))}
/>
<p className={styles.counter}>
{seoMetaDescription.length} / {DESCRIPTION_MAX}
</p>
</div>
)
}
@@ -519,6 +519,59 @@
color: #ef4444; color: #ef4444;
margin-bottom: 12px; margin-bottom: 12px;
} }
.smsReport {
display: flex;
flex-direction: column;
gap: 10px;
}
.smsReportSummary {
margin: 0;
font-size: var(--field-font-size);
color: var(--text-primary);
line-height: 1.5;
}
.smsReportSummaryOk {
color: var(--primary);
}
.smsReportFailures {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(40vh, 280px);
overflow-y: auto;
}
.smsReportFailure {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.06);
}
.smsReportPhone {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-ui);
direction: ltr;
text-align: start;
}
.smsReportError {
font-size: 12px;
color: #ef4444;
line-height: 1.4;
}
@keyframes overlayFadeIn { @keyframes overlayFadeIn {
from { opacity: 0; } from { opacity: 0; }
to { opacity: 1; } to { opacity: 1; }
@@ -0,0 +1,278 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
updateWebsiteSettings,
type WebsiteSchemaSettings,
type WebsiteSchemaType,
} from '../services/settingsService'
import { Tooltip } from './Tooltip'
import removeStyles from './VariationsModal.module.css'
import sharedStyles from '../pages/ProductSettingsPage.module.css'
import styles from '../pages/WebsiteSettingsPage.module.css'
const SCHEMA_TYPES: WebsiteSchemaType[] = ['Organization', 'LocalBusiness']
const EMPTY_SCHEMA: WebsiteSchemaSettings = {
enabled: true,
type: 'Organization',
name: null,
nameFa: null,
description: null,
priceRange: null,
sameAs: [],
}
interface WebsiteSchemaSettingsSectionProps {
initial: WebsiteSchemaSettings | null | undefined
disabled?: boolean
onSaved: (schema: WebsiteSchemaSettings) => void
}
export function WebsiteSchemaSettingsSection({
initial,
disabled = false,
onSaved,
}: WebsiteSchemaSettingsSectionProps) {
const t = useT()
const { showToast } = useToast()
const [draft, setDraft] = useState<WebsiteSchemaSettings>(initial ?? EMPTY_SCHEMA)
const [sameAsDraft, setSameAsDraft] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setDraft(initial ?? EMPTY_SCHEMA)
setError('')
}, [initial])
function updateField<K extends keyof WebsiteSchemaSettings>(
key: K,
value: WebsiteSchemaSettings[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }))
}
function addSameAs() {
const url = sameAsDraft.trim()
if (!url) return
if (!/^https?:\/\//i.test(url)) {
setError(t('website.settings.schema.sameAsInvalid'))
return
}
if (draft.sameAs.includes(url)) {
setSameAsDraft('')
return
}
updateField('sameAs', [...draft.sameAs, url].slice(0, 20))
setSameAsDraft('')
setError('')
}
function removeSameAs(url: string) {
updateField(
'sameAs',
draft.sameAs.filter((item) => item !== url),
)
}
async function handleSave() {
if (isSaving || disabled) return
setIsSaving(true)
setError('')
try {
const data = await updateWebsiteSettings({
schema: {
enabled: draft.enabled,
type: draft.type,
name: draft.name?.trim() || null,
nameFa: draft.nameFa?.trim() || null,
description: draft.description?.trim() || null,
priceRange: draft.priceRange?.trim() || null,
sameAs: draft.sameAs,
},
})
const saved = data.settings.website?.schema ?? draft
setDraft(saved)
onSaved(saved)
showToast(t('website.settings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.settings.errorSave'))
}
} finally {
setIsSaving(false)
}
}
return (
<section className={`${sharedStyles.panel} ${styles.schemaPanel}`}>
<div className={styles.schemaHeader}>
<div>
<h2 className={styles.schemaTitle}>{t('website.settings.schema.title')}</h2>
<p className={styles.schemaDesc}>{t('website.settings.schema.desc')}</p>
</div>
<label className={styles.schemaToggle}>
<input
type="checkbox"
checked={draft.enabled}
disabled={disabled || isSaving}
onChange={(e) => updateField('enabled', e.target.checked)}
/>
<span>{t('website.settings.schema.enabled')}</span>
</label>
</div>
{error ? (
<div className={sharedStyles.alertError} role="alert">
{error}
</div>
) : null}
<div className={styles.schemaGrid}>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-type">{t('website.settings.schema.type')}</label>
<select
id="website-schema-type"
value={draft.type}
disabled={disabled || isSaving || !draft.enabled}
onChange={(e) => updateField('type', e.target.value as WebsiteSchemaType)}
>
{SCHEMA_TYPES.map((type) => (
<option key={type} value={type}>
{type === 'Organization'
? t('website.settings.schema.typeOrganization')
: t('website.settings.schema.typeLocalBusiness')}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-name-fa">{t('website.settings.schema.nameFa')}</label>
<input
id="website-schema-name-fa"
type="text"
value={draft.nameFa ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.nameFaPlaceholder')}
onChange={(e) => updateField('nameFa', e.target.value || null)}
dir="rtl"
lang="fa"
/>
</div>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-name">{t('website.settings.schema.name')}</label>
<input
id="website-schema-name"
type="text"
value={draft.name ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.namePlaceholder')}
onChange={(e) => updateField('name', e.target.value || null)}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.schemaField} ${styles.schemaFieldFull}`}>
<label htmlFor="website-schema-description">
{t('website.settings.schema.description')}
</label>
<textarea
id="website-schema-description"
rows={3}
value={draft.description ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.descriptionPlaceholder')}
onChange={(e) => updateField('description', e.target.value || null)}
/>
</div>
{draft.type === 'LocalBusiness' ? (
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-price-range">
{t('website.settings.schema.priceRange')}
</label>
<input
id="website-schema-price-range"
type="text"
value={draft.priceRange ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.priceRangePlaceholder')}
onChange={(e) => updateField('priceRange', e.target.value || null)}
/>
</div>
) : null}
<div className={`${styles.field} ${styles.schemaField} ${styles.schemaFieldFull}`}>
<label htmlFor="website-schema-sameas">{t('website.settings.schema.sameAs')}</label>
<p className={styles.schemaHint}>{t('website.settings.schema.sameAsHint')}</p>
<div className={styles.sameAsAdd}>
<input
id="website-schema-sameas"
type="url"
value={sameAsDraft}
disabled={disabled || isSaving || !draft.enabled}
placeholder="https://…"
dir="ltr"
onChange={(e) => setSameAsDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addSameAs()
}
}}
/>
<Tooltip label={t('website.settings.schema.sameAsAdd')}>
<button
type="button"
className={styles.addSameAsBtn}
onClick={addSameAs}
disabled={disabled || isSaving || !draft.enabled || !sameAsDraft.trim()}
aria-label={t('website.settings.schema.sameAsAdd')}
>
<Plus size={16} />
</button>
</Tooltip>
</div>
{draft.sameAs.length > 0 ? (
<ul className={styles.sameAsList}>
{draft.sameAs.map((url) => (
<li key={url} className={styles.sameAsRow}>
<span dir="ltr">{url}</span>
<Tooltip label={t('website.settings.schema.sameAsRemove')}>
<button
type="button"
className={removeStyles.removeRowBtn}
onClick={() => removeSameAs(url)}
disabled={disabled || isSaving || !draft.enabled}
aria-label={t('website.settings.schema.sameAsRemove')}
>
<Trash2 size={14} />
</button>
</Tooltip>
</li>
))}
</ul>
) : null}
</div>
</div>
<div className={styles.schemaActions}>
<button
type="button"
className={styles.saveSchemaBtn}
onClick={() => void handleSave()}
disabled={disabled || isSaving}
>
{isSaving ? t('website.settings.schema.saving') : t('website.settings.schema.save')}
</button>
</div>
</section>
)
}
+20
View File
@@ -6,6 +6,8 @@ export const initialCategories: Category[] = [
nameEn: 'Software & Apps', nameEn: 'Software & Apps',
nameFa: 'نرم‌افزار و اپلیکیشن', nameFa: 'نرم‌افزار و اپلیکیشن',
description: 'Digital software, mobile and desktop applications.', description: 'Digital software, mobile and desktop applications.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -14,6 +16,8 @@ export const initialCategories: Category[] = [
nameEn: 'Mobile Apps', nameEn: 'Mobile Apps',
nameFa: 'اپلیکیشن موبایل', nameFa: 'اپلیکیشن موبایل',
description: 'iOS and Android applications.', description: 'iOS and Android applications.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: '1', parentId: '1',
}, },
@@ -22,6 +26,8 @@ export const initialCategories: Category[] = [
nameEn: 'Desktop Software', nameEn: 'Desktop Software',
nameFa: 'نرم‌افزار دسکتاپ', nameFa: 'نرم‌افزار دسکتاپ',
description: 'Windows, macOS and Linux software.', description: 'Windows, macOS and Linux software.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: '1', parentId: '1',
}, },
@@ -30,6 +36,8 @@ export const initialCategories: Category[] = [
nameEn: 'Digital Downloads', nameEn: 'Digital Downloads',
nameFa: 'دانلودهای دیجیتال', nameFa: 'دانلودهای دیجیتال',
description: 'Instant downloadable digital products.', description: 'Instant downloadable digital products.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -38,6 +46,8 @@ export const initialCategories: Category[] = [
nameEn: 'E-books & Courses', nameEn: 'E-books & Courses',
nameFa: 'کتاب الکترونیکی و دوره‌ها', nameFa: 'کتاب الکترونیکی و دوره‌ها',
description: 'Online learning materials and digital books.', description: 'Online learning materials and digital books.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -46,6 +56,8 @@ export const initialCategories: Category[] = [
nameEn: 'Templates & Assets', nameEn: 'Templates & Assets',
nameFa: 'قالب‌ها و دارایی‌ها', nameFa: 'قالب‌ها و دارایی‌ها',
description: 'Design templates, graphics and digital assets.', description: 'Design templates, graphics and digital assets.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -54,6 +66,8 @@ export const initialCategories: Category[] = [
nameEn: 'Subscriptions', nameEn: 'Subscriptions',
nameFa: 'اشتراک‌ها', nameFa: 'اشتراک‌ها',
description: 'Recurring digital subscription services.', description: 'Recurring digital subscription services.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -62,6 +76,8 @@ export const initialCategories: Category[] = [
nameEn: 'Gaming', nameEn: 'Gaming',
nameFa: 'بازی', nameFa: 'بازی',
description: 'Digital games and gaming content.', description: 'Digital games and gaming content.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: null, parentId: null,
}, },
@@ -70,6 +86,8 @@ export const initialCategories: Category[] = [
nameEn: 'PC Games', nameEn: 'PC Games',
nameFa: 'بازی‌های کامپیوتر', nameFa: 'بازی‌های کامپیوتر',
description: 'Games for Windows, macOS and Linux.', description: 'Games for Windows, macOS and Linux.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: '8', parentId: '8',
}, },
@@ -78,6 +96,8 @@ export const initialCategories: Category[] = [
nameEn: 'Console Games', nameEn: 'Console Games',
nameFa: 'بازی‌های کنسول', nameFa: 'بازی‌های کنسول',
description: 'PlayStation, Xbox and Nintendo games.', description: 'PlayStation, Xbox and Nintendo games.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0, variationCount: 0,
parentId: '8', parentId: '8',
}, },
+73
View File
@@ -668,6 +668,11 @@ const en = {
'categories.modal.cancel': 'Cancel', 'categories.modal.cancel': 'Cancel',
'categories.modal.save': 'Save Category', 'categories.modal.save': 'Save Category',
'categories.modal.saving': 'Saving...', 'categories.modal.saving': 'Saving...',
'seo.meta.hint': 'Optional. Leave empty to use the page title and summary on the website.',
'seo.meta.title': 'SEO title override',
'seo.meta.titlePlaceholder': 'Browser tab / search title',
'seo.meta.description': 'SEO description override',
'seo.meta.descriptionPlaceholder': 'Meta description for search results',
'categories.select.search': 'Search categories...', 'categories.select.search': 'Search categories...',
'categories.select.none': '— No parent (top-level category) —', 'categories.select.none': '— No parent (top-level category) —',
'categories.select.empty': 'No categories found', 'categories.select.empty': 'No categories found',
@@ -1132,6 +1137,12 @@ const en = {
'customers.smsModal.sending': 'Sending…', 'customers.smsModal.sending': 'Sending…',
'customers.smsModal.error': 'Unable to send SMS.', 'customers.smsModal.error': 'Unable to send SMS.',
'customers.smsModal.errorDisabled': 'SMS is disabled. Message was not sent.', 'customers.smsModal.errorDisabled': 'SMS is disabled. Message was not sent.',
'customers.smsModal.reportTitle': 'Send report',
'customers.smsModal.reportAllSent': 'All {count} messages were sent successfully.',
'customers.smsModal.reportPartial':
'Sent {sent} of {total}. {failed} failed — details below.',
'customers.smsModal.reportNone': 'No messages were sent. {failed} failed — details below.',
'customers.smsModal.reportDone': 'Done',
'customers.smsModal.template': 'Template', 'customers.smsModal.template': 'Template',
'customers.smsModal.templateNone': 'Custom message (no template)', 'customers.smsModal.templateNone': 'Custom message (no template)',
'customers.smsModal.templateHintVerified': 'customers.smsModal.templateHintVerified':
@@ -1154,6 +1165,8 @@ const en = {
'customers.toast.smsSoon': 'SMS to {phone} is not available yet.', 'customers.toast.smsSoon': 'SMS to {phone} is not available yet.',
'customers.toast.smsSent': 'SMS sent to {phone}.', 'customers.toast.smsSent': 'SMS sent to {phone}.',
'customers.toast.smsSentBulk': 'SMS sent to {count} recipients.', 'customers.toast.smsSentBulk': 'SMS sent to {count} recipients.',
'customers.toast.smsSentBulkPartial':
'SMS sent to {sent} of {total} recipients ({failed} failed).',
'customers.toast.ticketsSoon': 'Tickets for "{name}" are not available yet.', 'customers.toast.ticketsSoon': 'Tickets for "{name}" are not available yet.',
'customers.deleteTitle': 'Remove customer?', 'customers.deleteTitle': 'Remove customer?',
'customers.deleteMessage': 'customers.deleteMessage':
@@ -2003,6 +2016,30 @@ const en = {
'website.settings.sitemapImported': 'Imported from website', 'website.settings.sitemapImported': 'Imported from website',
'website.settings.sitemapSyncedAt': 'Last synced {date}', 'website.settings.sitemapSyncedAt': 'Last synced {date}',
'website.settings.sitemapSummary': '{pages} static pages · {templates} URL templates', 'website.settings.sitemapSummary': '{pages} static pages · {templates} URL templates',
'website.settings.schema.title': 'Structured data (Schema.org)',
'website.settings.schema.desc':
'Site-wide Organization or LocalBusiness JSON-LD for search engines. Websites read a ready-made jsonLd from business-info.',
'website.settings.schema.enabled': 'Enable structured data',
'website.settings.schema.type': 'Type',
'website.settings.schema.typeOrganization': 'Organization',
'website.settings.schema.typeLocalBusiness': 'Local business',
'website.settings.schema.name': 'Name (EN override)',
'website.settings.schema.namePlaceholder': 'Leave empty to use business EN name',
'website.settings.schema.nameFa': 'Name (FA override)',
'website.settings.schema.nameFaPlaceholder': 'Leave empty to use business FA name',
'website.settings.schema.description': 'Description override',
'website.settings.schema.descriptionPlaceholder':
'Leave empty to use the business about text',
'website.settings.schema.priceRange': 'Price range',
'website.settings.schema.priceRangePlaceholder': 'e.g. $$ or 100,000–5,000,000 IRT',
'website.settings.schema.sameAs': 'Extra profile URLs (sameAs)',
'website.settings.schema.sameAsHint':
'Optional. Social links from Business profile are included automatically. Add full https URLs only.',
'website.settings.schema.sameAsAdd': 'Add URL',
'website.settings.schema.sameAsRemove': 'Remove URL',
'website.settings.schema.sameAsInvalid': 'URL must start with http:// or https://',
'website.settings.schema.save': 'Save schema settings',
'website.settings.schema.saving': 'Saving…',
'website.pick.addOneItem': 'Add 1 item', 'website.pick.addOneItem': 'Add 1 item',
'website.pick.addItems': 'Add {count} items', 'website.pick.addItems': 'Add {count} items',
@@ -3138,6 +3175,11 @@ const fa: Record<MessageKey, string> = {
'categories.modal.cancel': 'انصراف', 'categories.modal.cancel': 'انصراف',
'categories.modal.save': 'ذخیره دسته‌بندی', 'categories.modal.save': 'ذخیره دسته‌بندی',
'categories.modal.saving': 'در حال ذخیره...', 'categories.modal.saving': 'در حال ذخیره...',
'seo.meta.hint': 'اختیاری. برای استفاده از عنوان و خلاصه صفحه در وب‌سایت، خالی بگذارید.',
'seo.meta.title': 'عنوان SEO (جایگزین)',
'seo.meta.titlePlaceholder': 'عنوان تب مرورگر / نتایج جستجو',
'seo.meta.description': 'توضیحات SEO (جایگزین)',
'seo.meta.descriptionPlaceholder': 'توضیحات متا برای نتایج جستجو',
'categories.select.search': 'جستجوی دسته‌بندی‌ها...', 'categories.select.search': 'جستجوی دسته‌بندی‌ها...',
'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —', 'categories.select.none': '— بدون دسته والد (دسته سطح بالا) —',
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد', 'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
@@ -3602,6 +3644,12 @@ const fa: Record<MessageKey, string> = {
'customers.smsModal.sending': 'در حال ارسال…', 'customers.smsModal.sending': 'در حال ارسال…',
'customers.smsModal.error': 'ارسال پیامک ممکن نشد.', 'customers.smsModal.error': 'ارسال پیامک ممکن نشد.',
'customers.smsModal.errorDisabled': 'پیامک غیرفعال است. پیام ارسال نشد.', 'customers.smsModal.errorDisabled': 'پیامک غیرفعال است. پیام ارسال نشد.',
'customers.smsModal.reportTitle': 'گزارش ارسال',
'customers.smsModal.reportAllSent': 'همه {count} پیام با موفقیت ارسال شد.',
'customers.smsModal.reportPartial':
'{sent} از {total} ارسال شد. {failed} ناموفق — جزئیات زیر.',
'customers.smsModal.reportNone': 'هیچ پیامی ارسال نشد. {failed} ناموفق — جزئیات زیر.',
'customers.smsModal.reportDone': 'بستن',
'customers.smsModal.template': 'قالب', 'customers.smsModal.template': 'قالب',
'customers.smsModal.templateNone': 'پیام سفارشی (بدون قالب)', 'customers.smsModal.templateNone': 'پیام سفارشی (بدون قالب)',
'customers.smsModal.templateHintVerified': 'customers.smsModal.templateHintVerified':
@@ -3624,6 +3672,8 @@ const fa: Record<MessageKey, string> = {
'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.', 'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.',
'customers.toast.smsSent': 'پیامک به {phone} ارسال شد.', 'customers.toast.smsSent': 'پیامک به {phone} ارسال شد.',
'customers.toast.smsSentBulk': 'پیامک به {count} گیرنده ارسال شد.', 'customers.toast.smsSentBulk': 'پیامک به {count} گیرنده ارسال شد.',
'customers.toast.smsSentBulkPartial':
'پیامک به {sent} از {total} گیرنده ارسال شد ({failed} ناموفق).',
'customers.toast.ticketsSoon': 'تیکت‌های «{name}» هنوز در دسترس نیست.', 'customers.toast.ticketsSoon': 'تیکت‌های «{name}» هنوز در دسترس نیست.',
'customers.deleteTitle': 'حذف مشتری؟', 'customers.deleteTitle': 'حذف مشتری؟',
'customers.deleteMessage': 'customers.deleteMessage':
@@ -4473,6 +4523,29 @@ const fa: Record<MessageKey, string> = {
'website.settings.sitemapImported': 'از وب‌سایت import شده', 'website.settings.sitemapImported': 'از وب‌سایت import شده',
'website.settings.sitemapSyncedAt': 'آخرین همگام‌سازی {date}', 'website.settings.sitemapSyncedAt': 'آخرین همگام‌سازی {date}',
'website.settings.sitemapSummary': '{pages} صفحه ثابت · {templates} الگوی URL', 'website.settings.sitemapSummary': '{pages} صفحه ثابت · {templates} الگوی URL',
'website.settings.schema.title': 'داده ساخت‌یافته (Schema.org)',
'website.settings.schema.desc':
'JSON-LD سازمانی یا کسب‌وکار محلی برای موتورهای جستجو. وب‌سایت‌ها jsonLd آماده را از business-info می‌خوانند.',
'website.settings.schema.enabled': 'فعال‌سازی داده ساخت‌یافته',
'website.settings.schema.type': 'نوع',
'website.settings.schema.typeOrganization': 'سازمان (Organization)',
'website.settings.schema.typeLocalBusiness': 'کسب‌وکار محلی (LocalBusiness)',
'website.settings.schema.name': 'نام انگلیسی (اختیاری)',
'website.settings.schema.namePlaceholder': 'خالی = نام انگلیسی کسب‌وکار',
'website.settings.schema.nameFa': 'نام فارسی (اختیاری)',
'website.settings.schema.nameFaPlaceholder': 'خالی = نام فارسی کسب‌وکار',
'website.settings.schema.description': 'توضیحات (اختیاری)',
'website.settings.schema.descriptionPlaceholder': 'خالی = متن درباره کسب‌وکار',
'website.settings.schema.priceRange': 'بازه قیمت',
'website.settings.schema.priceRangePlaceholder': 'مثلاً $$ یا ۱۰۰٬۰۰۰–۵٬۰۰۰٬۰۰۰ تومان',
'website.settings.schema.sameAs': 'لینک‌های اضافی (sameAs)',
'website.settings.schema.sameAsHint':
'اختیاری. شبکه‌های اجتماعی پروفایل کسب‌وکار خودکار اضافه می‌شوند. فقط آدرس کامل https.',
'website.settings.schema.sameAsAdd': 'افزودن آدرس',
'website.settings.schema.sameAsRemove': 'حذف آدرس',
'website.settings.schema.sameAsInvalid': 'آدرس باید با http:// یا https:// شروع شود',
'website.settings.schema.save': 'ذخیره تنظیمات اسکیما',
'website.settings.schema.saving': 'در حال ذخیره…',
'website.pick.addOneItem': 'افزودن ۱ قلم', 'website.pick.addOneItem': 'افزودن ۱ قلم',
'website.pick.addItems': 'افزودن {count} قلم', 'website.pick.addItems': 'افزودن {count} قلم',
@@ -11,6 +11,7 @@ import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect' import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages' import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api' import { ApiError, isAbortError } from '../lib/api'
@@ -34,6 +35,7 @@ import {
} from '../services/userProductsService' } from '../services/userProductsService'
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService' import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta'
import styles from './AddCustomerProductPage.module.css' import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3 type StepId = 1 | 2 | 3
@@ -110,6 +112,8 @@ export function AddCustomerProductPage() {
const [titleFa, setTitleFa] = useState('') const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('') const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('') const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [priceInput, setPriceInput] = useState('') const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT') const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false) const [priceByExpert, setPriceByExpert] = useState(false)
@@ -207,6 +211,8 @@ export function AddCustomerProductPage() {
setTitleFa(product.titleFa ?? product.title ?? '') setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '') setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '') setDescription(product.description ?? '')
setSeoMetaTitle(product.seoMetaTitle ?? '')
setSeoMetaDescription(product.seoMetaDescription ?? '')
setPriceInput( setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '', product.price != null ? formatIrtInput(String(product.price)) : '',
) )
@@ -376,6 +382,7 @@ export function AddCustomerProductPage() {
titleFa: titleFa.trim(), titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined, titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined, description: description.trim() || undefined,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
categoryId, categoryId,
price: price ?? undefined, price: price ?? undefined,
priceCurrency: priceUnit, priceCurrency: priceUnit,
@@ -617,6 +624,19 @@ export function AddCustomerProductPage() {
/> />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={submitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
<div className={styles.priceRow}> <div className={styles.priceRow}>
<label <label
className={`${styles.checkRow} ${styles.col6}`} className={`${styles.checkRow} ${styles.col6}`}
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog' import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog' import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewBlogPage() {
const [titleImage, setTitleImage] = useState<string | null>(null) const [titleImage, setTitleImage] = useState<string | null>(null)
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2') const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null) const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -99,6 +103,8 @@ export function AddNewBlogPage() {
setMainTextHtml(form.mainTextHtml) setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage) setTitleImage(form.titleImage)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId) setFeaturedMediaId(form.featuredMediaId)
setLoaded(true) setLoaded(true)
} catch (err) { } catch (err) {
@@ -134,6 +140,7 @@ export function AddNewBlogPage() {
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined, featuredMediaId: nextFeaturedMediaId ?? undefined,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -317,6 +324,19 @@ export function AddNewBlogPage() {
<label>{t('blog.form.tags')}</label> <label>{t('blog.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction' import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction' import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -54,6 +56,8 @@ export function AddNewInstructionPage() {
const [coverImage, setCoverImage] = useState<string | null>(null) const [coverImage, setCoverImage] = useState<string | null>(null)
const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2') const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null) const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -103,6 +107,8 @@ export function AddNewInstructionPage() {
setEmbedCode(form.embedCode) setEmbedCode(form.embedCode)
setCoverImage(form.coverImage) setCoverImage(form.coverImage)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId) setFeaturedMediaId(form.featuredMediaId)
setLoaded(true) setLoaded(true)
} catch (err) { } catch (err) {
@@ -141,6 +147,7 @@ export function AddNewInstructionPage() {
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined, featuredMediaId: nextFeaturedMediaId ?? undefined,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -360,6 +367,19 @@ export function AddNewInstructionPage() {
<label>{t('instruction.form.tags')}</label> <label>{t('instruction.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -27,6 +28,7 @@ import {
} from '../services/portfolioService' } from '../services/portfolioService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -60,6 +62,8 @@ export function AddNewPortfolioPage() {
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2') const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null) const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([]) const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
@@ -110,6 +114,8 @@ export function AddNewPortfolioPage() {
setTitleImage(form.titleImage) setTitleImage(form.titleImage)
setImages(form.images) setImages(form.images)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId) setFeaturedMediaId(form.featuredMediaId)
setGalleryMediaIds(form.galleryMediaIds) setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true) setLoaded(true)
@@ -155,6 +161,7 @@ export function AddNewPortfolioPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined, featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds, galleryMediaIds: nextGalleryMediaIds,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -371,6 +378,19 @@ export function AddNewPortfolioPage() {
<label>{t('portfolio.form.tags')}</label> <label>{t('portfolio.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -6,6 +6,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal' import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip' import { Tooltip } from '../components/Tooltip'
@@ -29,6 +30,7 @@ import {
} from '../services/productAiService' } from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category' import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css' import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState<string | null>(null) const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null) const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([]) const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
@@ -171,6 +175,8 @@ export function AddNewProductPage() {
setThumbnail(form.thumbnail) setThumbnail(form.thumbnail)
setImages(form.images) setImages(form.images)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setThumbnailMediaId(form.thumbnailMediaId) setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds) setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true) setLoaded(true)
@@ -219,6 +225,7 @@ export function AddNewProductPage() {
featuredMediaId: featuredMediaId ?? undefined, featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds, galleryMediaIds: nextGalleryMediaIds,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -438,6 +445,19 @@ export function AddNewProductPage() {
placeholder={t('products.form.tagsPlaceholder')} placeholder={t('products.form.tagsPlaceholder')}
/> />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper' import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video' import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video' import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -46,6 +48,8 @@ export function AddNewVideoPage() {
const [embedCode, setEmbedCode] = useState('') const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null) const [coverImage, setCoverImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null) const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit) const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -93,6 +97,8 @@ export function AddNewVideoPage() {
setEmbedCode(form.embedCode) setEmbedCode(form.embedCode)
setCoverImage(form.coverImage) setCoverImage(form.coverImage)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId) setFeaturedMediaId(form.featuredMediaId)
setLoaded(true) setLoaded(true)
} catch (err) { } catch (err) {
@@ -130,6 +136,7 @@ export function AddNewVideoPage() {
categoryId: categoryId || undefined, categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined, featuredMediaId: nextFeaturedMediaId ?? undefined,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const, status: 'published' as const,
} }
@@ -322,6 +329,19 @@ export function AddNewVideoPage() {
<label>{t('video.form.tags')}</label> <label>{t('video.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader' import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput' import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor' import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { JalaliDatePicker } from '../components/JalaliDatePicker' import { JalaliDatePicker } from '../components/JalaliDatePicker'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
@@ -28,6 +29,7 @@ import {
} from '../services/workshopService' } from '../services/workshopService'
import type { Category } from '../types/category' import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories' import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css' import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css' import styles from './AddNewProductPage.module.css'
@@ -47,6 +49,8 @@ export function AddNewWorkshopPage() {
const [coverImage, setCoverImage] = useState<string | null>(null) const [coverImage, setCoverImage] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([]) const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([]) const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [eventDate, setEventDate] = useState('') const [eventDate, setEventDate] = useState('')
const [eventTime, setEventTime] = useState('') const [eventTime, setEventTime] = useState('')
const [duration, setDuration] = useState('') const [duration, setDuration] = useState('')
@@ -99,6 +103,8 @@ export function AddNewWorkshopPage() {
setCoverImage(form.coverImage) setCoverImage(form.coverImage)
setImages(form.images) setImages(form.images)
setTags(form.tags) setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setEventDate(form.eventDate) setEventDate(form.eventDate)
setEventTime(form.eventTime) setEventTime(form.eventTime)
setDuration(form.duration) setDuration(form.duration)
@@ -166,6 +172,7 @@ export function AddNewWorkshopPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined, featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds, galleryMediaIds: nextGalleryMediaIds,
tags, tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
eventDate: eventDate.trim() || null, eventDate: eventDate.trim() || null,
eventTime: eventTime.trim() || null, eventTime: eventTime.trim() || null,
duration: duration.trim() || null, duration: duration.trim() || null,
@@ -369,6 +376,19 @@ export function AddNewWorkshopPage() {
<label>{t('workshop.form.tags')}</label> <label>{t('workshop.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} /> <TagInput tags={tags} onChange={setTags} />
</div> </div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div> </div>
<div className={styles.actions}> <div className={styles.actions}>
+12
View File
@@ -912,6 +912,18 @@ export function CustomersPage() {
onSent={(info) => { onSent={(info) => {
if (info.mode === 'single' && info.phone) { if (info.mode === 'single' && info.phone) {
showToast(t('customers.toast.smsSent', { phone: info.phone }), 'success') showToast(t('customers.toast.smsSent', { phone: info.phone }), 'success')
} else if (info.failedCount && info.failedCount > 0 && info.totalCount) {
showToast(
t('customers.toast.smsSentBulkPartial', {
sent: info.count,
total: info.totalCount,
failed: info.failedCount,
}),
'info',
)
if (info.mode === 'selected') {
setSelectedPeople(new Map())
}
} else { } else {
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success') showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
if (info.mode === 'selected') { if (info.mode === 'selected') {
@@ -102,6 +102,194 @@
margin-top: 24px; margin-top: 24px;
} }
.schemaPanel {
margin-top: 24px;
}
.schemaHeader {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 16px;
margin-bottom: 16px;
}
.schemaTitle {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-ui);
}
.schemaDesc {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
max-width: 52ch;
}
.schemaToggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
cursor: pointer;
user-select: none;
}
.schemaToggle input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
.schemaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 14px;
}
.schemaField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.schemaField label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.schemaFieldFull {
grid-column: 1 / -1;
}
.schemaField input,
.schemaField textarea {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.schemaField textarea {
min-height: 84px;
resize: vertical;
}
.schemaField input:focus,
.schemaField textarea:focus,
.schemaField select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.schemaHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.sameAsAdd {
display: flex;
gap: 8px;
align-items: center;
}
.sameAsAdd input {
flex: 1;
min-width: 0;
}
.addSameAsBtn {
width: var(--field-height);
height: var(--field-height);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.22);
border-radius: var(--radius-sm);
cursor: pointer;
}
.addSameAsBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sameAsList {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.sameAsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.2);
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
}
.sameAsRow span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schemaActions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.saveSchemaBtn {
min-height: var(--field-height);
padding: 0 16px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.saveSchemaBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) { @media (max-width: 768px) {
.field, .field,
.sitemapActions { .sitemapActions {
@@ -116,6 +304,10 @@
.sitemapUrl { .sitemapUrl {
max-width: none; max-width: none;
} }
.schemaGrid {
grid-template-columns: 1fr;
}
} }
.spin { .spin {
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react' import { RefreshCw } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs' import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle' import { PageTitle } from '../components/PageTitle'
import { WebsiteSchemaSettingsSection } from '../components/WebsiteSchemaSettingsSection'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { useToast } from '../context/ToastContext' import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
@@ -11,6 +12,7 @@ import {
getSettings, getSettings,
updateWebsiteSettings, updateWebsiteSettings,
type SpecialProductsSource, type SpecialProductsSource,
type WebsiteSchemaSettings,
type WebsiteSitemapConfig, type WebsiteSitemapConfig,
} from '../services/settingsService' } from '../services/settingsService'
import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService' import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService'
@@ -47,6 +49,7 @@ export function WebsiteSettingsPage() {
const storeModuleEnabled = hasBusinessModule(enabledModules, 'store') const storeModuleEnabled = hasBusinessModule(enabledModules, 'store')
const [source, setSource] = useState<SpecialProductsSource>('product') const [source, setSource] = useState<SpecialProductsSource>('product')
const [sitemapConfig, setSitemapConfig] = useState<WebsiteSitemapConfig | null>(null) const [sitemapConfig, setSitemapConfig] = useState<WebsiteSitemapConfig | null>(null)
const [schema, setSchema] = useState<WebsiteSchemaSettings | null>(null)
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false) const [isSaving, setIsSaving] = useState(false)
const [isSyncingSitemap, setIsSyncingSitemap] = useState(false) const [isSyncingSitemap, setIsSyncingSitemap] = useState(false)
@@ -69,6 +72,7 @@ export function WebsiteSettingsPage() {
defaultSpecialProductsSource(data.settings.modules?.enabled), defaultSpecialProductsSource(data.settings.modules?.enabled),
) )
setSitemapConfig(data.settings.website?.sitemapConfig ?? null) setSitemapConfig(data.settings.website?.sitemapConfig ?? null)
setSchema(data.settings.website?.schema ?? null)
} catch (err) { } catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) { if (err instanceof ApiError) {
@@ -92,6 +96,9 @@ export function WebsiteSettingsPage() {
try { try {
const data = await updateWebsiteSettings({ specialProductsSource: next }) const data = await updateWebsiteSettings({ specialProductsSource: next })
setSource(data.settings.website?.specialProductsSource ?? next) setSource(data.settings.website?.specialProductsSource ?? next)
if (data.settings.website?.schema) {
setSchema(data.settings.website.schema)
}
showToast(t('website.settings.saved'), 'success') showToast(t('website.settings.saved'), 'success')
} catch (err) { } catch (err) {
setSource(previous) setSource(previous)
@@ -222,6 +229,14 @@ export function WebsiteSettingsPage() {
)} )}
</section> </section>
{!isLoading ? (
<WebsiteSchemaSettingsSection
initial={schema}
disabled={isSaving}
onSaved={setSchema}
/>
) : null}
{storeModuleEnabled ? ( {storeModuleEnabled ? (
<div className={`${styles.ePaymentBlock} ${settingsStyles.stack}`}> <div className={`${styles.ePaymentBlock} ${settingsStyles.stack}`}>
<EPaymentSettingsSection /> <EPaymentSettingsSection />
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'blog' as const const ENTITY_TYPE = 'blog' as const
@@ -61,6 +62,7 @@ export function toBlogCategoryFormPayload(data: CategoryFormData): CreateCategor
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -70,6 +72,8 @@ export function mapBlogCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
} }
@@ -17,6 +17,8 @@ export interface BlogApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
titleImageUrl: string | null titleImageUrl: string | null
featuredMediaId: string | null featuredMediaId: string | null
authorId?: string | null authorId?: string | null
@@ -60,6 +62,8 @@ export interface CreateBlogPayload {
status?: BlogStatus status?: BlogStatus
featuredMediaId?: string featuredMediaId?: string
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export interface UpdateBlogPayload { export interface UpdateBlogPayload {
@@ -71,6 +75,8 @@ export interface UpdateBlogPayload {
status?: BlogStatus status?: BlogStatus
featuredMediaId?: string | null featuredMediaId?: string | null
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export function resolveBlogTitleImageUrl(blog: BlogApi): string | null { export function resolveBlogTitleImageUrl(blog: BlogApi): string | null {
@@ -155,6 +161,8 @@ export function mapBlogApiToFormState(blog: BlogApi) {
mainTextHtml: blog.mainTextHtml, mainTextHtml: blog.mainTextHtml,
categoryId: blog.categoryId ?? '', categoryId: blog.categoryId ?? '',
tags: blog.tags, tags: blog.tags,
seoMetaTitle: blog.seoMetaTitle ?? '',
seoMetaDescription: blog.seoMetaDescription ?? '',
titleImage: resolveBlogTitleImageUrl(blog), titleImage: resolveBlogTitleImageUrl(blog),
featuredMediaId: blog.featuredMediaId, featuredMediaId: blog.featuredMediaId,
} }
@@ -106,6 +106,8 @@ export function mapBrandToSelectOption(brand: BrandApi): FlatCategory {
nameEn: brand.nameEn, nameEn: brand.nameEn,
nameFa: brand.nameFa ?? '', nameFa: brand.nameFa ?? '',
description: brand.about ?? '', description: brand.about ?? '',
seoMetaTitle: '',
seoMetaDescription: '',
parentId: null, parentId: null,
variationCount: 0, variationCount: 0,
depth: 0, depth: 0,
@@ -189,7 +189,10 @@ export interface SendCustomersBulkSmsResult {
enabled: boolean enabled: boolean
message: string message: string
recipientCount: number recipientCount: number
sentCount?: number
failedCount?: number
serverIds?: string[] serverIds?: string[]
failures?: Array<{ cellNumber: string; error: string }>
source?: string source?: string
} }
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'instruction' as const const ENTITY_TYPE = 'instruction' as const
@@ -61,6 +62,7 @@ export function toInstructionCategoryFormPayload(data: CategoryFormData): Create
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -70,6 +72,8 @@ export function mapInstructionCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
} }
@@ -25,6 +25,8 @@ export interface InstructionApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null coverImageUrl: string | null
featuredMediaId: string | null featuredMediaId: string | null
authorId?: string | null authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateInstructionPayload {
status?: InstructionStatus status?: InstructionStatus
featuredMediaId?: string featuredMediaId?: string
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export interface UpdateInstructionPayload { export interface UpdateInstructionPayload {
@@ -83,6 +87,8 @@ export interface UpdateInstructionPayload {
status?: InstructionStatus status?: InstructionStatus
featuredMediaId?: string | null featuredMediaId?: string | null
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export function resolveInstructionTitle( export function resolveInstructionTitle(
@@ -170,6 +176,8 @@ export function mapInstructionApiToFormState(instruction: InstructionApi) {
embedCode: instruction.embedCode, embedCode: instruction.embedCode,
categoryId: instruction.categoryId ?? '', categoryId: instruction.categoryId ?? '',
tags: instruction.tags, tags: instruction.tags,
seoMetaTitle: instruction.seoMetaTitle ?? '',
seoMetaDescription: instruction.seoMetaDescription ?? '',
coverImage: instruction.coverImageUrl, coverImage: instruction.coverImageUrl,
featuredMediaId: instruction.featuredMediaId, featuredMediaId: instruction.featuredMediaId,
} }
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'portfolio' as const const ENTITY_TYPE = 'portfolio' as const
@@ -61,6 +62,7 @@ export function toPortfolioCategoryFormPayload(data: CategoryFormData): CreateCa
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -70,6 +72,8 @@ export function mapPortfolioCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
} }
@@ -19,6 +19,8 @@ export interface PortfolioApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
titleImageUrl: string | null titleImageUrl: string | null
featuredMediaId: string | null featuredMediaId: string | null
gallery: PortfolioGalleryItem[] gallery: PortfolioGalleryItem[]
@@ -66,6 +68,8 @@ export interface CreatePortfolioPayload {
featuredMediaId?: string featuredMediaId?: string
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder?: number sortOrder?: number
} }
@@ -82,6 +86,8 @@ export interface UpdatePortfolioPayload {
featuredMediaId?: string | null featuredMediaId?: string | null
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder?: number sortOrder?: number
/** Pin to absolute top of the business portfolio list. */ /** Pin to absolute top of the business portfolio list. */
moveToTop?: boolean moveToTop?: boolean
@@ -171,6 +177,8 @@ export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
projectUrl: portfolio.projectUrl?.trim() || '', projectUrl: portfolio.projectUrl?.trim() || '',
categoryId: portfolio.categoryId ?? '', categoryId: portfolio.categoryId ?? '',
tags: portfolio.tags, tags: portfolio.tags,
seoMetaTitle: portfolio.seoMetaTitle ?? '',
seoMetaDescription: portfolio.seoMetaDescription ?? '',
titleImage: resolvePortfolioTitleImageUrl(portfolio), titleImage: resolvePortfolioTitleImageUrl(portfolio),
featuredMediaId: portfolio.featuredMediaId, featuredMediaId: portfolio.featuredMediaId,
images: portfolio.gallery.map((item) => item.url), images: portfolio.gallery.map((item) => item.url),
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'product' as const const ENTITY_TYPE = 'product' as const
@@ -75,6 +76,7 @@ export function toCategoryFormPayload(data: CategoryFormData): CreateCategoryPay
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -84,6 +86,8 @@ export function mapProductCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
translationCount: category.translationCount ?? 0, translationCount: category.translationCount ?? 0,
@@ -27,6 +27,8 @@ export interface ProductApi {
imageUrl: string | null imageUrl: string | null
} | null } | null
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
thumbnailUrl: string | null thumbnailUrl: string | null
thumbnailMediaId: string | null thumbnailMediaId: string | null
image: string image: string
@@ -58,6 +60,8 @@ export interface CreateProductPayload {
featuredMediaId?: string featuredMediaId?: string
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export interface UpdateProductPayload { export interface UpdateProductPayload {
@@ -71,6 +75,8 @@ export interface UpdateProductPayload {
featuredMediaId?: string | null featuredMediaId?: string | null
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
function businessPath(suffix = '') { function businessPath(suffix = '') {
@@ -145,6 +151,8 @@ export function mapProductApiToFormState(product: ProductApi) {
thumbnail: product.thumbnailUrl ?? product.thumbnail ?? null, thumbnail: product.thumbnailUrl ?? product.thumbnail ?? null,
images: product.images.map((item) => item.url), images: product.images.map((item) => item.url),
tags: product.tags, tags: product.tags,
seoMetaTitle: product.seoMetaTitle ?? '',
seoMetaDescription: product.seoMetaDescription ?? '',
thumbnailMediaId: product.thumbnailMediaId, thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds, galleryMediaIds: product.galleryMediaIds,
} }
@@ -91,6 +91,18 @@ export interface ModulesSettings {
export type SpecialProductsSource = 'product' | 'store_item' 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 { export interface WebsiteSitemapConfig {
baseUrl?: string | null baseUrl?: string | null
staticPages?: Array<{ staticPages?: Array<{
@@ -105,6 +117,7 @@ export interface WebsiteSitemapConfig {
export interface WebsiteSettings { export interface WebsiteSettings {
specialProductsSource: SpecialProductsSource specialProductsSource: SpecialProductsSource
sitemapConfig?: WebsiteSitemapConfig | null sitemapConfig?: WebsiteSitemapConfig | null
schema?: WebsiteSchemaSettings
} }
export interface SmsSettings { export interface SmsSettings {
@@ -61,6 +61,8 @@ export interface CreateUserProductInput {
technicalValues?: UserProductTechnicalValueInput[] technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string featuredMediaId?: string
galleryMediaIds?: string[] galleryMediaIds?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export type UpdateUserProductInput = CreateUserProductInput export type UpdateUserProductInput = CreateUserProductInput
@@ -77,6 +79,8 @@ export interface UserProductDetail extends UserProductListItem {
technicalNotes?: string | null technicalNotes?: string | null
description?: string | null description?: string | null
titleEn?: string | null titleEn?: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
priceCurrency?: string | null priceCurrency?: string | null
priceByExpert?: boolean priceByExpert?: boolean
promoted?: boolean promoted?: boolean
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'video' as const const ENTITY_TYPE = 'video' as const
@@ -61,6 +62,7 @@ export function toVideoCategoryFormPayload(data: CategoryFormData): CreateCatego
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -70,6 +72,8 @@ export function mapVideoCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
} }
@@ -25,6 +25,8 @@ export interface VideoApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null coverImageUrl: string | null
featuredMediaId: string | null featuredMediaId: string | null
authorId?: string | null authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateVideoPayload {
status?: VideoStatus status?: VideoStatus
featuredMediaId?: string featuredMediaId?: string
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export interface UpdateVideoPayload { export interface UpdateVideoPayload {
@@ -83,6 +87,8 @@ export interface UpdateVideoPayload {
status?: VideoStatus status?: VideoStatus
featuredMediaId?: string | null featuredMediaId?: string | null
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
} }
export function resolveVideoTitle( export function resolveVideoTitle(
@@ -170,6 +176,8 @@ export function mapVideoApiToFormState(video: VideoApi) {
embedCode: video.embedCode, embedCode: video.embedCode,
categoryId: video.categoryId ?? '', categoryId: video.categoryId ?? '',
tags: video.tags, tags: video.tags,
seoMetaTitle: video.seoMetaTitle ?? '',
seoMetaDescription: video.seoMetaDescription ?? '',
coverImage: video.coverImageUrl, coverImage: video.coverImageUrl,
featuredMediaId: video.featuredMediaId, featuredMediaId: video.featuredMediaId,
} }
@@ -7,6 +7,7 @@ import type {
ProductCategory, ProductCategory,
UpdateCategoryPayload, UpdateCategoryPayload,
} from '../types/category' } from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'workshop' as const const ENTITY_TYPE = 'workshop' as const
@@ -61,6 +62,7 @@ export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCat
name: data.nameEn, name: data.nameEn,
nameFa: data.nameFa, nameFa: data.nameFa,
description: data.description || undefined, description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
} }
} }
@@ -70,6 +72,8 @@ export function mapWorkshopCategoryToUi(category: ProductCategory) {
nameEn: category.name, nameEn: category.name,
nameFa: category.nameFa ?? '', nameFa: category.nameFa ?? '',
description: category.description ?? '', description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId, parentId: category.parentId,
variationCount: category.variationCount ?? 0, variationCount: category.variationCount ?? 0,
} }
@@ -24,6 +24,8 @@ export interface WorkshopApi {
categoryId: string | null categoryId: string | null
categoryName: string categoryName: string
tags: string[] tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null coverImageUrl: string | null
featuredMediaId: string | null featuredMediaId: string | null
gallery: WorkshopGalleryItem[] gallery: WorkshopGalleryItem[]
@@ -93,6 +95,8 @@ export interface CreateWorkshopPayload {
featuredMediaId?: string featuredMediaId?: string
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
eventDate?: string | null eventDate?: string | null
eventTime?: string | null eventTime?: string | null
duration?: string | null duration?: string | null
@@ -108,6 +112,8 @@ export interface UpdateWorkshopPayload {
featuredMediaId?: string | null featuredMediaId?: string | null
galleryMediaIds?: string[] galleryMediaIds?: string[]
tags?: string[] tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
eventDate?: string | null eventDate?: string | null
eventTime?: string | null eventTime?: string | null
duration?: string | null duration?: string | null
@@ -291,6 +297,8 @@ export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml), descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
categoryId: workshop.categoryId ?? '', categoryId: workshop.categoryId ?? '',
tags: Array.isArray(workshop.tags) ? workshop.tags : [], tags: Array.isArray(workshop.tags) ? workshop.tags : [],
seoMetaTitle: workshop.seoMetaTitle ?? '',
seoMetaDescription: workshop.seoMetaDescription ?? '',
eventDate: normalizeGregorianDate(workshop.eventDate), eventDate: normalizeGregorianDate(workshop.eventDate),
eventTime: normalizeTimeInput(workshop.eventTime), eventTime: normalizeTimeInput(workshop.eventTime),
duration: duration:
+10
View File
@@ -3,6 +3,8 @@ export interface Category {
nameEn: string nameEn: string
nameFa: string nameFa: string
description: string description: string
seoMetaTitle: string
seoMetaDescription: string
parentId: string | null parentId: string | null
variationCount: number variationCount: number
translationCount?: number translationCount?: number
@@ -13,6 +15,8 @@ export interface CategoryFormData {
nameFa: string nameFa: string
nameEn: string nameEn: string
description: string description: string
seoMetaTitle: string
seoMetaDescription: string
} }
export interface FlatCategory extends Category { export interface FlatCategory extends Category {
@@ -28,6 +32,8 @@ export interface ProductCategory {
nameFa: string | null nameFa: string | null
slug: string slug: string
description: string | null description: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder: number sortOrder: number
isActive: boolean isActive: boolean
variationCount?: number variationCount?: number
@@ -45,6 +51,8 @@ export interface CreateCategoryPayload {
name: string name: string
nameFa?: string nameFa?: string
description?: string description?: string
seoMetaTitle?: string | null
seoMetaDescription?: string | null
slug?: string slug?: string
sortOrder?: number sortOrder?: number
} }
@@ -54,6 +62,8 @@ export interface UpdateCategoryPayload {
name?: string name?: string
nameFa?: string | null nameFa?: string | null
description?: string | null description?: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
slug?: string slug?: string
sortOrder?: number sortOrder?: number
isActive?: boolean isActive?: boolean
+9
View File
@@ -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,
}
}
+3 -1
View File
@@ -217,7 +217,7 @@ Add to `/etc/hosts` (one line per tenant):
| `/website` | Website hub | Placeholder | | `/website` | Website hub | Placeholder |
| `/website/static-images` | Named website image slots, grouped by page | Yes | | `/website/static-images` | Named website image slots, grouped by page | Yes |
| `/website/special-items` | Special item carousels | 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/contact` | Contact us submissions list | Yes |
| `/website/subscriptions` | Subscriptions | Placeholder | | `/website/subscriptions` | Subscriptions | Placeholder |
| `/website/faq` | FAQ | Placeholder | | `/website/faq` | FAQ | Placeholder |
@@ -277,6 +277,8 @@ Products accept optional `brandId` on create/update.
### Business settings ### Business settings
- `GET/PATCH /businesses/:businessId/settings` — `branding`, `dashboard`, `store`, `modules`, `website` - `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.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 `<script type="application/ld+json">`. When enabled, public product/blog **detail** responses also include `schema.jsonLd` (`Product` / `BlogPosting`).
- Per-entity optional SEO overrides (`seoMetaTitle`, `seoMetaDescription`) on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories (stored in `metadata` JSON).
- **Torob:** store-module websites with Store settings → Torob on. Nginx on the shop apex proxies `POST /torob_api/v3/products` to `POST /api/v1/tenants/{domain}/torob_api/v3/products`. Otherwise 404. Do not implement this in Next.js. - **Torob:** store-module websites with Store settings → Torob on. Nginx on the shop apex proxies `POST /torob_api/v3/products` to `POST /api/v1/tenants/{domain}/torob_api/v3/products`. Otherwise 404. Do not implement this in Next.js.
- `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`, `finance`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH. - `modules.enabled`: optional modules — business: `products`, `store`, `portfolio`, `blog`, `warehouse`, `videos`, `finance`; customer: `customer_products`. Missing key → all business modules enabled (legacy); customer modules stay opt-in. Super-admin only for modules PATCH.
- `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`, `product_views_30d`, `website_views_30d`, `portfolio_views_30d`); `none` hides that slot; defaults orders + customers. View charts read `website_page_views` (auto-counted on public list/detail GETs; ~6 month retention). `website_views_30d` aggregates **all** page kinds (`kind=all`); product/portfolio/blog charts filter by detail kind. Home-only hits also increment on `GET .../website/sliders` and `GET .../website/static-images?pageKey=home`. Lifetime totals in `website_page_view_counters`. - `modules.charts`: two home chart slots (`none`, `orders_30d`, `customers_joined_1y`, `blog_views_30d`, `products_added_1y`, `product_views_30d`, `website_views_30d`, `portfolio_views_30d`); `none` hides that slot; defaults orders + customers. View charts read `website_page_views` (auto-counted on public list/detail GETs; ~6 month retention). `website_views_30d` aggregates **all** page kinds (`kind=all`); product/portfolio/blog charts filter by detail kind. Home-only hits also increment on `GET .../website/sliders` and `GET .../website/static-images?pageKey=home`. Lifetime totals in `website_page_view_counters`.