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 { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css'
interface CategoryModalProps {
@@ -41,6 +42,8 @@ export function CategoryModal({
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
useEffect(() => {
if (open) {
@@ -50,6 +53,8 @@ export function CategoryModal({
setNameEn(editingCategory?.nameEn ?? '')
setNameFa(editingCategory?.nameFa ?? '')
setDescription(editingCategory?.description ?? '')
setSeoMetaTitle(editingCategory?.seoMetaTitle ?? '')
setSeoMetaDescription(editingCategory?.seoMetaDescription ?? '')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -78,6 +83,8 @@ export function CategoryModal({
nameFa: nameFa.trim(),
nameEn: nameEn.trim(),
description: description.trim(),
seoMetaTitle,
seoMetaDescription,
})
}
@@ -170,6 +177,19 @@ export function CategoryModal({
/>
</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}>
<button
type="button"
@@ -9,6 +9,7 @@ import {
sendCustomerSms,
sendCustomersBulkSms,
type BusinessCustomerListItem,
type SendCustomersBulkSmsResult,
} from '../services/customerService'
import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService'
import modalStyles from './VariationsModal.module.css'
@@ -23,7 +24,13 @@ interface SendCustomerSmsModalProps {
open: boolean
target: SmsComposeTarget | null
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
@@ -47,6 +54,7 @@ export function SendCustomerSmsModal({
const [templatesLoading, setTemplatesLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [bulkReport, setBulkReport] = useState<SendCustomersBulkSmsResult | null>(null)
const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
@@ -56,6 +64,7 @@ export function SendCustomerSmsModal({
setMessage('')
setTemplateId('')
setError('')
setBulkReport(null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -98,6 +107,7 @@ export function SendCustomerSmsModal({
const selectedTemplate = templates.find((item) => item.id === templateId) ?? null
const trimmed = message.trim()
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
const showingReport = bulkReport !== null
function applyTemplate(nextId: string) {
setTemplateId(nextId)
@@ -125,6 +135,7 @@ export function SendCustomerSmsModal({
if (!target || !canSend) return
setIsSubmitting(true)
setError('')
setBulkReport(null)
try {
const tplId = templateId || null
if (target.mode === 'single') {
@@ -138,6 +149,7 @@ export function SendCustomerSmsModal({
count: 1,
phone: formatCellForDisplay(target.customer.cellNumber),
})
onClose()
} else {
const userIds = target.mode === 'selected' ? target.userIds : undefined
const result = await sendCustomersBulkSms(trimmed, userIds, tplId)
@@ -145,9 +157,27 @@ export function SendCustomerSmsModal({
setError(result.message || t('customers.smsModal.errorDisabled'))
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) {
setError(err instanceof ApiError ? err.message : t('customers.smsModal.error'))
} finally {
@@ -156,7 +186,9 @@ export function SendCustomerSmsModal({
}
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 name = [target.customer.firstName, target.customer.lastName]
.filter(Boolean)
@@ -169,6 +201,20 @@ export function SendCustomerSmsModal({
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(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -202,94 +248,125 @@ export function SendCustomerSmsModal({
</div>
<div className={modalStyles.body}>
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.field}>
<label htmlFor="customer-sms-template">{t('customers.smsModal.template')}</label>
<select
id="customer-sms-template"
value={templateId}
onChange={(e) => applyTemplate(e.target.value)}
disabled={isSubmitting || templatesLoading}
>
<option value="">{t('customers.smsModal.templateNone')}</option>
{templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
{tpl.isVerified ? ' ✓' : ''}
</option>
))}
</select>
{templatesLoading ? (
<p className={modalStyles.modalHint}>{t('customers.smsModal.templateLoading')}</p>
) : null}
{selectedTemplate ? (
<p className={modalStyles.modalHint}>
{selectedTemplate.isVerified
? t('customers.smsModal.templateHintVerified')
: t('customers.smsModal.templateHintPending')}
{showingReport ? (
<div className={modalStyles.smsReport}>
<p
className={`${modalStyles.smsReportSummary} ${
reportFailed === 0 ? modalStyles.smsReportSummaryOk : ''
}`}
>
{reportSummary}
</p>
) : null}
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<div
className={settingsStyles.smsVarChips}
role="group"
aria-label={t('settings.sms.variablesHint')}
>
{SMS_VARS.map((token) => (
<button
key={token}
type="button"
className={settingsStyles.smsVarChip}
onClick={() => insertVariable(token)}
disabled={
isSubmitting ||
Boolean(selectedTemplate?.isVerified) ||
message.length >= MAX_LEN
}
dir="ltr"
>
{token}
{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>
<textarea
id="customer-sms-message"
ref={messageRef}
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5}
disabled={isSubmitting || Boolean(selectedTemplate?.isVerified)}
placeholder={t('customers.smsModal.placeholder')}
dir={isFa ? 'rtl' : 'auto'}
readOnly={Boolean(selectedTemplate?.isVerified)}
/>
<p className={modalStyles.modalHint}>
{message.length} / {MAX_LEN}
</p>
</div>
) : (
<>
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('customers.smsModal.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button>
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-template">{t('customers.smsModal.template')}</label>
<select
id="customer-sms-template"
value={templateId}
onChange={(e) => applyTemplate(e.target.value)}
disabled={isSubmitting || templatesLoading}
>
<option value="">{t('customers.smsModal.templateNone')}</option>
{templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
{tpl.isVerified ? ' ✓' : ''}
</option>
))}
</select>
{templatesLoading ? (
<p className={modalStyles.modalHint}>{t('customers.smsModal.templateLoading')}</p>
) : null}
{selectedTemplate ? (
<p className={modalStyles.modalHint}>
{selectedTemplate.isVerified
? t('customers.smsModal.templateHintVerified')
: t('customers.smsModal.templateHintPending')}
</p>
) : null}
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<div
className={settingsStyles.smsVarChips}
role="group"
aria-label={t('settings.sms.variablesHint')}
>
{SMS_VARS.map((token) => (
<button
key={token}
type="button"
className={settingsStyles.smsVarChip}
onClick={() => insertVariable(token)}
disabled={
isSubmitting ||
Boolean(selectedTemplate?.isVerified) ||
message.length >= MAX_LEN
}
dir="ltr"
>
{token}
</button>
))}
</div>
<textarea
id="customer-sms-message"
ref={messageRef}
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5}
disabled={isSubmitting || Boolean(selectedTemplate?.isVerified)}
placeholder={t('customers.smsModal.placeholder')}
dir={isFa ? 'rtl' : 'auto'}
readOnly={Boolean(selectedTemplate?.isVerified)}
/>
<p className={modalStyles.modalHint}>
{message.length} / {MAX_LEN}
</p>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('customers.smsModal.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button>
</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;
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 {
from { opacity: 0; }
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',
nameFa: 'نرم‌افزار و اپلیکیشن',
description: 'Digital software, mobile and desktop applications.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -14,6 +16,8 @@ export const initialCategories: Category[] = [
nameEn: 'Mobile Apps',
nameFa: 'اپلیکیشن موبایل',
description: 'iOS and Android applications.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: '1',
},
@@ -22,6 +26,8 @@ export const initialCategories: Category[] = [
nameEn: 'Desktop Software',
nameFa: 'نرم‌افزار دسکتاپ',
description: 'Windows, macOS and Linux software.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: '1',
},
@@ -30,6 +36,8 @@ export const initialCategories: Category[] = [
nameEn: 'Digital Downloads',
nameFa: 'دانلودهای دیجیتال',
description: 'Instant downloadable digital products.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -38,6 +46,8 @@ export const initialCategories: Category[] = [
nameEn: 'E-books & Courses',
nameFa: 'کتاب الکترونیکی و دوره‌ها',
description: 'Online learning materials and digital books.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -46,6 +56,8 @@ export const initialCategories: Category[] = [
nameEn: 'Templates & Assets',
nameFa: 'قالب‌ها و دارایی‌ها',
description: 'Design templates, graphics and digital assets.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -54,6 +66,8 @@ export const initialCategories: Category[] = [
nameEn: 'Subscriptions',
nameFa: 'اشتراک‌ها',
description: 'Recurring digital subscription services.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -62,6 +76,8 @@ export const initialCategories: Category[] = [
nameEn: 'Gaming',
nameFa: 'بازی',
description: 'Digital games and gaming content.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: null,
},
@@ -70,6 +86,8 @@ export const initialCategories: Category[] = [
nameEn: 'PC Games',
nameFa: 'بازی‌های کامپیوتر',
description: 'Games for Windows, macOS and Linux.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: '8',
},
@@ -78,6 +96,8 @@ export const initialCategories: Category[] = [
nameEn: 'Console Games',
nameFa: 'بازی‌های کنسول',
description: 'PlayStation, Xbox and Nintendo games.',
seoMetaTitle: '',
seoMetaDescription: '',
variationCount: 0,
parentId: '8',
},
+73
View File
@@ -668,6 +668,11 @@ const en = {
'categories.modal.cancel': 'Cancel',
'categories.modal.save': 'Save Category',
'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.none': '— No parent (top-level category) —',
'categories.select.empty': 'No categories found',
@@ -1132,6 +1137,12 @@ const en = {
'customers.smsModal.sending': 'Sending…',
'customers.smsModal.error': 'Unable to send SMS.',
'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.templateNone': 'Custom message (no template)',
'customers.smsModal.templateHintVerified':
@@ -1154,6 +1165,8 @@ const en = {
'customers.toast.smsSoon': 'SMS to {phone} is not available yet.',
'customers.toast.smsSent': 'SMS sent to {phone}.',
'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.deleteTitle': 'Remove customer?',
'customers.deleteMessage':
@@ -2003,6 +2016,30 @@ const en = {
'website.settings.sitemapImported': 'Imported from website',
'website.settings.sitemapSyncedAt': 'Last synced {date}',
'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.addItems': 'Add {count} items',
@@ -3138,6 +3175,11 @@ const fa: Record<MessageKey, string> = {
'categories.modal.cancel': 'انصراف',
'categories.modal.save': 'ذخیره دسته‌بندی',
'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.none': '— بدون دسته والد (دسته سطح بالا) —',
'categories.select.empty': 'دسته‌بندی‌ای یافت نشد',
@@ -3602,6 +3644,12 @@ const fa: Record<MessageKey, string> = {
'customers.smsModal.sending': 'در حال ارسال…',
'customers.smsModal.error': 'ارسال پیامک ممکن نشد.',
'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.templateNone': 'پیام سفارشی (بدون قالب)',
'customers.smsModal.templateHintVerified':
@@ -3624,6 +3672,8 @@ const fa: Record<MessageKey, string> = {
'customers.toast.smsSoon': 'ارسال پیامک به {phone} هنوز در دسترس نیست.',
'customers.toast.smsSent': 'پیامک به {phone} ارسال شد.',
'customers.toast.smsSentBulk': 'پیامک به {count} گیرنده ارسال شد.',
'customers.toast.smsSentBulkPartial':
'پیامک به {sent} از {total} گیرنده ارسال شد ({failed} ناموفق).',
'customers.toast.ticketsSoon': 'تیکت‌های «{name}» هنوز در دسترس نیست.',
'customers.deleteTitle': 'حذف مشتری؟',
'customers.deleteMessage':
@@ -4473,6 +4523,29 @@ const fa: Record<MessageKey, string> = {
'website.settings.sitemapImported': 'از وب‌سایت import شده',
'website.settings.sitemapSyncedAt': 'آخرین همگام‌سازی {date}',
'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.addItems': 'افزودن {count} قلم',
@@ -11,6 +11,7 @@ import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
@@ -34,6 +35,7 @@ import {
} from '../services/userProductsService'
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta'
import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3
@@ -110,6 +112,8 @@ export function AddCustomerProductPage() {
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
@@ -207,6 +211,8 @@ export function AddCustomerProductPage() {
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setSeoMetaTitle(product.seoMetaTitle ?? '')
setSeoMetaDescription(product.seoMetaDescription ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
@@ -376,6 +382,7 @@ export function AddCustomerProductPage() {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
@@ -617,6 +624,19 @@ export function AddCustomerProductPage() {
/>
</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}>
<label
className={`${styles.checkRow} ${styles.col6}`}
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewBlogPage() {
const [titleImage, setTitleImage] = useState<string | null>(null)
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -99,6 +103,8 @@ export function AddNewBlogPage() {
setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -134,6 +140,7 @@ export function AddNewBlogPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -317,6 +324,19 @@ export function AddNewBlogPage() {
<label>{t('blog.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</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 className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -54,6 +56,8 @@ export function AddNewInstructionPage() {
const [coverImage, setCoverImage] = useState<string | null>(null)
const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -103,6 +107,8 @@ export function AddNewInstructionPage() {
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -141,6 +147,7 @@ export function AddNewInstructionPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -360,6 +367,19 @@ export function AddNewInstructionPage() {
<label>{t('instruction.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</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 className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -27,6 +28,7 @@ import {
} from '../services/portfolioService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -60,6 +62,8 @@ export function AddNewPortfolioPage() {
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
@@ -110,6 +114,8 @@ export function AddNewPortfolioPage() {
setTitleImage(form.titleImage)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
@@ -155,6 +161,7 @@ export function AddNewPortfolioPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -371,6 +378,19 @@ export function AddNewPortfolioPage() {
<label>{t('portfolio.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</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 className={styles.actions}>
@@ -6,6 +6,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip'
@@ -29,6 +30,7 @@ import {
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
@@ -171,6 +175,8 @@ export function AddNewProductPage() {
setThumbnail(form.thumbnail)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
@@ -219,6 +225,7 @@ export function AddNewProductPage() {
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -438,6 +445,19 @@ export function AddNewProductPage() {
placeholder={t('products.form.tagsPlaceholder')}
/>
</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 className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -46,6 +48,8 @@ export function AddNewVideoPage() {
const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -93,6 +97,8 @@ export function AddNewVideoPage() {
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -130,6 +136,7 @@ export function AddNewVideoPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -322,6 +329,19 @@ export function AddNewVideoPage() {
<label>{t('video.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</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 className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { JalaliDatePicker } from '../components/JalaliDatePicker'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
@@ -28,6 +29,7 @@ import {
} from '../services/workshopService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -47,6 +49,8 @@ export function AddNewWorkshopPage() {
const [coverImage, setCoverImage] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [eventDate, setEventDate] = useState('')
const [eventTime, setEventTime] = useState('')
const [duration, setDuration] = useState('')
@@ -99,6 +103,8 @@ export function AddNewWorkshopPage() {
setCoverImage(form.coverImage)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setEventDate(form.eventDate)
setEventTime(form.eventTime)
setDuration(form.duration)
@@ -166,6 +172,7 @@ export function AddNewWorkshopPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
eventDate: eventDate.trim() || null,
eventTime: eventTime.trim() || null,
duration: duration.trim() || null,
@@ -369,6 +376,19 @@ export function AddNewWorkshopPage() {
<label>{t('workshop.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</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 className={styles.actions}>
+12
View File
@@ -912,6 +912,18 @@ export function CustomersPage() {
onSent={(info) => {
if (info.mode === 'single' && info.phone) {
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 {
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
if (info.mode === 'selected') {
@@ -102,6 +102,194 @@
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) {
.field,
.sitemapActions {
@@ -116,6 +304,10 @@
.sitemapUrl {
max-width: none;
}
.schemaGrid {
grid-template-columns: 1fr;
}
}
.spin {
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { WebsiteSchemaSettingsSection } from '../components/WebsiteSchemaSettingsSection'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -11,6 +12,7 @@ import {
getSettings,
updateWebsiteSettings,
type SpecialProductsSource,
type WebsiteSchemaSettings,
type WebsiteSitemapConfig,
} from '../services/settingsService'
import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService'
@@ -47,6 +49,7 @@ export function WebsiteSettingsPage() {
const storeModuleEnabled = hasBusinessModule(enabledModules, 'store')
const [source, setSource] = useState<SpecialProductsSource>('product')
const [sitemapConfig, setSitemapConfig] = useState<WebsiteSitemapConfig | null>(null)
const [schema, setSchema] = useState<WebsiteSchemaSettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isSyncingSitemap, setIsSyncingSitemap] = useState(false)
@@ -69,6 +72,7 @@ export function WebsiteSettingsPage() {
defaultSpecialProductsSource(data.settings.modules?.enabled),
)
setSitemapConfig(data.settings.website?.sitemapConfig ?? null)
setSchema(data.settings.website?.schema ?? null)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
@@ -92,6 +96,9 @@ export function WebsiteSettingsPage() {
try {
const data = await updateWebsiteSettings({ specialProductsSource: next })
setSource(data.settings.website?.specialProductsSource ?? next)
if (data.settings.website?.schema) {
setSchema(data.settings.website.schema)
}
showToast(t('website.settings.saved'), 'success')
} catch (err) {
setSource(previous)
@@ -222,6 +229,14 @@ export function WebsiteSettingsPage() {
)}
</section>
{!isLoading ? (
<WebsiteSchemaSettingsSection
initial={schema}
disabled={isSaving}
onSaved={setSchema}
/>
) : null}
{storeModuleEnabled ? (
<div className={`${styles.ePaymentBlock} ${settingsStyles.stack}`}>
<EPaymentSettingsSection />
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'blog' as const
@@ -61,6 +62,7 @@ export function toBlogCategoryFormPayload(data: CategoryFormData): CreateCategor
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapBlogCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
@@ -17,6 +17,8 @@ export interface BlogApi {
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
titleImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -60,6 +62,8 @@ export interface CreateBlogPayload {
status?: BlogStatus
featuredMediaId?: string
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export interface UpdateBlogPayload {
@@ -71,6 +75,8 @@ export interface UpdateBlogPayload {
status?: BlogStatus
featuredMediaId?: string | null
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export function resolveBlogTitleImageUrl(blog: BlogApi): string | null {
@@ -155,6 +161,8 @@ export function mapBlogApiToFormState(blog: BlogApi) {
mainTextHtml: blog.mainTextHtml,
categoryId: blog.categoryId ?? '',
tags: blog.tags,
seoMetaTitle: blog.seoMetaTitle ?? '',
seoMetaDescription: blog.seoMetaDescription ?? '',
titleImage: resolveBlogTitleImageUrl(blog),
featuredMediaId: blog.featuredMediaId,
}
@@ -106,6 +106,8 @@ export function mapBrandToSelectOption(brand: BrandApi): FlatCategory {
nameEn: brand.nameEn,
nameFa: brand.nameFa ?? '',
description: brand.about ?? '',
seoMetaTitle: '',
seoMetaDescription: '',
parentId: null,
variationCount: 0,
depth: 0,
@@ -189,7 +189,10 @@ export interface SendCustomersBulkSmsResult {
enabled: boolean
message: string
recipientCount: number
sentCount?: number
failedCount?: number
serverIds?: string[]
failures?: Array<{ cellNumber: string; error: string }>
source?: string
}
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'instruction' as const
@@ -61,6 +62,7 @@ export function toInstructionCategoryFormPayload(data: CategoryFormData): Create
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapInstructionCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
@@ -25,6 +25,8 @@ export interface InstructionApi {
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateInstructionPayload {
status?: InstructionStatus
featuredMediaId?: string
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export interface UpdateInstructionPayload {
@@ -83,6 +87,8 @@ export interface UpdateInstructionPayload {
status?: InstructionStatus
featuredMediaId?: string | null
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export function resolveInstructionTitle(
@@ -170,6 +176,8 @@ export function mapInstructionApiToFormState(instruction: InstructionApi) {
embedCode: instruction.embedCode,
categoryId: instruction.categoryId ?? '',
tags: instruction.tags,
seoMetaTitle: instruction.seoMetaTitle ?? '',
seoMetaDescription: instruction.seoMetaDescription ?? '',
coverImage: instruction.coverImageUrl,
featuredMediaId: instruction.featuredMediaId,
}
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'portfolio' as const
@@ -61,6 +62,7 @@ export function toPortfolioCategoryFormPayload(data: CategoryFormData): CreateCa
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapPortfolioCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
@@ -19,6 +19,8 @@ export interface PortfolioApi {
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
titleImageUrl: string | null
featuredMediaId: string | null
gallery: PortfolioGalleryItem[]
@@ -66,6 +68,8 @@ export interface CreatePortfolioPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder?: number
}
@@ -82,6 +86,8 @@ export interface UpdatePortfolioPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder?: number
/** Pin to absolute top of the business portfolio list. */
moveToTop?: boolean
@@ -171,6 +177,8 @@ export function mapPortfolioApiToFormState(portfolio: PortfolioApi) {
projectUrl: portfolio.projectUrl?.trim() || '',
categoryId: portfolio.categoryId ?? '',
tags: portfolio.tags,
seoMetaTitle: portfolio.seoMetaTitle ?? '',
seoMetaDescription: portfolio.seoMetaDescription ?? '',
titleImage: resolvePortfolioTitleImageUrl(portfolio),
featuredMediaId: portfolio.featuredMediaId,
images: portfolio.gallery.map((item) => item.url),
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'product' as const
@@ -75,6 +76,7 @@ export function toCategoryFormPayload(data: CategoryFormData): CreateCategoryPay
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -84,6 +86,8 @@ export function mapProductCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
translationCount: category.translationCount ?? 0,
@@ -27,6 +27,8 @@ export interface ProductApi {
imageUrl: string | null
} | null
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
thumbnailUrl: string | null
thumbnailMediaId: string | null
image: string
@@ -58,6 +60,8 @@ export interface CreateProductPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export interface UpdateProductPayload {
@@ -71,6 +75,8 @@ export interface UpdateProductPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
function businessPath(suffix = '') {
@@ -145,6 +151,8 @@ export function mapProductApiToFormState(product: ProductApi) {
thumbnail: product.thumbnailUrl ?? product.thumbnail ?? null,
images: product.images.map((item) => item.url),
tags: product.tags,
seoMetaTitle: product.seoMetaTitle ?? '',
seoMetaDescription: product.seoMetaDescription ?? '',
thumbnailMediaId: product.thumbnailMediaId,
galleryMediaIds: product.galleryMediaIds,
}
@@ -91,6 +91,18 @@ export interface ModulesSettings {
export type SpecialProductsSource = 'product' | 'store_item'
export type WebsiteSchemaType = 'Organization' | 'LocalBusiness'
export interface WebsiteSchemaSettings {
enabled: boolean
type: WebsiteSchemaType
name: string | null
nameFa: string | null
description: string | null
priceRange: string | null
sameAs: string[]
}
export interface WebsiteSitemapConfig {
baseUrl?: string | null
staticPages?: Array<{
@@ -105,6 +117,7 @@ export interface WebsiteSitemapConfig {
export interface WebsiteSettings {
specialProductsSource: SpecialProductsSource
sitemapConfig?: WebsiteSitemapConfig | null
schema?: WebsiteSchemaSettings
}
export interface SmsSettings {
@@ -61,6 +61,8 @@ export interface CreateUserProductInput {
technicalValues?: UserProductTechnicalValueInput[]
featuredMediaId?: string
galleryMediaIds?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export type UpdateUserProductInput = CreateUserProductInput
@@ -77,6 +79,8 @@ export interface UserProductDetail extends UserProductListItem {
technicalNotes?: string | null
description?: string | null
titleEn?: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
priceCurrency?: string | null
priceByExpert?: boolean
promoted?: boolean
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'video' as const
@@ -61,6 +62,7 @@ export function toVideoCategoryFormPayload(data: CategoryFormData): CreateCatego
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapVideoCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
@@ -25,6 +25,8 @@ export interface VideoApi {
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
@@ -70,6 +72,8 @@ export interface CreateVideoPayload {
status?: VideoStatus
featuredMediaId?: string
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export interface UpdateVideoPayload {
@@ -83,6 +87,8 @@ export interface UpdateVideoPayload {
status?: VideoStatus
featuredMediaId?: string | null
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
}
export function resolveVideoTitle(
@@ -170,6 +176,8 @@ export function mapVideoApiToFormState(video: VideoApi) {
embedCode: video.embedCode,
categoryId: video.categoryId ?? '',
tags: video.tags,
seoMetaTitle: video.seoMetaTitle ?? '',
seoMetaDescription: video.seoMetaDescription ?? '',
coverImage: video.coverImageUrl,
featuredMediaId: video.featuredMediaId,
}
@@ -7,6 +7,7 @@ import type {
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
import { seoMetaPayload } from '../utils/seoMeta'
const ENTITY_TYPE = 'workshop' as const
@@ -61,6 +62,7 @@ export function toWorkshopCategoryFormPayload(data: CategoryFormData): CreateCat
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
...seoMetaPayload(data.seoMetaTitle, data.seoMetaDescription),
}
}
@@ -70,6 +72,8 @@ export function mapWorkshopCategoryToUi(category: ProductCategory) {
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
seoMetaTitle: category.seoMetaTitle ?? '',
seoMetaDescription: category.seoMetaDescription ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
@@ -24,6 +24,8 @@ export interface WorkshopApi {
categoryId: string | null
categoryName: string
tags: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
coverImageUrl: string | null
featuredMediaId: string | null
gallery: WorkshopGalleryItem[]
@@ -93,6 +95,8 @@ export interface CreateWorkshopPayload {
featuredMediaId?: string
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
eventDate?: string | null
eventTime?: string | null
duration?: string | null
@@ -108,6 +112,8 @@ export interface UpdateWorkshopPayload {
featuredMediaId?: string | null
galleryMediaIds?: string[]
tags?: string[]
seoMetaTitle?: string | null
seoMetaDescription?: string | null
eventDate?: string | null
eventTime?: string | null
duration?: string | null
@@ -291,6 +297,8 @@ export function mapWorkshopApiToFormState(workshop: WorkshopApi) {
descriptionHtml: normalizeRichTextHtml(workshop.descriptionHtml),
categoryId: workshop.categoryId ?? '',
tags: Array.isArray(workshop.tags) ? workshop.tags : [],
seoMetaTitle: workshop.seoMetaTitle ?? '',
seoMetaDescription: workshop.seoMetaDescription ?? '',
eventDate: normalizeGregorianDate(workshop.eventDate),
eventTime: normalizeTimeInput(workshop.eventTime),
duration:
+10
View File
@@ -3,6 +3,8 @@ export interface Category {
nameEn: string
nameFa: string
description: string
seoMetaTitle: string
seoMetaDescription: string
parentId: string | null
variationCount: number
translationCount?: number
@@ -13,6 +15,8 @@ export interface CategoryFormData {
nameFa: string
nameEn: string
description: string
seoMetaTitle: string
seoMetaDescription: string
}
export interface FlatCategory extends Category {
@@ -28,6 +32,8 @@ export interface ProductCategory {
nameFa: string | null
slug: string
description: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
sortOrder: number
isActive: boolean
variationCount?: number
@@ -45,6 +51,8 @@ export interface CreateCategoryPayload {
name: string
nameFa?: string
description?: string
seoMetaTitle?: string | null
seoMetaDescription?: string | null
slug?: string
sortOrder?: number
}
@@ -54,6 +62,8 @@ export interface UpdateCategoryPayload {
name?: string
nameFa?: string | null
description?: string | null
seoMetaTitle?: string | null
seoMetaDescription?: string | null
slug?: string
sortOrder?: number
isActive?: boolean
+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,
}
}