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:
co-authored by
Cursor
parent
15aad9f9d8
commit
c2ede07e73
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user