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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user