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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-09 23:30:23 +03:30
co-authored by Cursor
parent 15aad9f9d8
commit c2ede07e73
37 changed files with 1217 additions and 89 deletions
@@ -6,6 +6,7 @@ import { useT } from '../i18n/useT'
import type { Category, CategoryFormData } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { SearchableSelect } from './SearchableSelect'
import { SeoMetaFields } from './SeoMetaFields'
import styles from './CategoryModal.module.css'
interface CategoryModalProps {
@@ -41,6 +42,8 @@ export function CategoryModal({
const [nameEn, setNameEn] = useState('')
const [nameFa, setNameFa] = useState('')
const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
useEffect(() => {
if (open) {
@@ -50,6 +53,8 @@ export function CategoryModal({
setNameEn(editingCategory?.nameEn ?? '')
setNameFa(editingCategory?.nameFa ?? '')
setDescription(editingCategory?.description ?? '')
setSeoMetaTitle(editingCategory?.seoMetaTitle ?? '')
setSeoMetaDescription(editingCategory?.seoMetaDescription ?? '')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -78,6 +83,8 @@ export function CategoryModal({
nameFa: nameFa.trim(),
nameEn: nameEn.trim(),
description: description.trim(),
seoMetaTitle,
seoMetaDescription,
})
}
@@ -170,6 +177,19 @@ export function CategoryModal({
/>
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
<div className={styles.actions}>
<button
type="button"
@@ -9,6 +9,7 @@ import {
sendCustomerSms,
sendCustomersBulkSms,
type BusinessCustomerListItem,
type SendCustomersBulkSmsResult,
} from '../services/customerService'
import { listSmsTemplates, type SmsTemplate } from '../services/smsTemplateService'
import modalStyles from './VariationsModal.module.css'
@@ -23,7 +24,13 @@ interface SendCustomerSmsModalProps {
open: boolean
target: SmsComposeTarget | null
onClose: () => void
onSent: (info: { mode: SmsComposeTarget['mode']; count: number; phone?: string }) => void
onSent: (info: {
mode: SmsComposeTarget['mode']
count: number
phone?: string
failedCount?: number
totalCount?: number
}) => void
}
const ANIMATION_MS = 220
@@ -47,6 +54,7 @@ export function SendCustomerSmsModal({
const [templatesLoading, setTemplatesLoading] = useState(false)
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
const [bulkReport, setBulkReport] = useState<SendCustomersBulkSmsResult | null>(null)
const messageRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
@@ -56,6 +64,7 @@ export function SendCustomerSmsModal({
setMessage('')
setTemplateId('')
setError('')
setBulkReport(null)
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
@@ -98,6 +107,7 @@ export function SendCustomerSmsModal({
const selectedTemplate = templates.find((item) => item.id === templateId) ?? null
const trimmed = message.trim()
const canSend = trimmed.length > 0 && trimmed.length <= MAX_LEN
const showingReport = bulkReport !== null
function applyTemplate(nextId: string) {
setTemplateId(nextId)
@@ -125,6 +135,7 @@ export function SendCustomerSmsModal({
if (!target || !canSend) return
setIsSubmitting(true)
setError('')
setBulkReport(null)
try {
const tplId = templateId || null
if (target.mode === 'single') {
@@ -138,6 +149,7 @@ export function SendCustomerSmsModal({
count: 1,
phone: formatCellForDisplay(target.customer.cellNumber),
})
onClose()
} else {
const userIds = target.mode === 'selected' ? target.userIds : undefined
const result = await sendCustomersBulkSms(trimmed, userIds, tplId)
@@ -145,9 +157,27 @@ export function SendCustomerSmsModal({
setError(result.message || t('customers.smsModal.errorDisabled'))
return
}
onSent({ mode: target.mode, count: result.recipientCount })
const sentCount = result.sentCount ?? result.recipientCount
const failedCount = result.failedCount ?? result.failures?.length ?? 0
const totalCount = result.recipientCount
if (sentCount > 0) {
onSent({
mode: target.mode,
count: sentCount,
failedCount,
totalCount,
})
}
if (failedCount > 0) {
setBulkReport(result)
return
}
onClose()
}
onClose()
} catch (err) {
setError(err instanceof ApiError ? err.message : t('customers.smsModal.error'))
} finally {
@@ -156,7 +186,9 @@ export function SendCustomerSmsModal({
}
let subtitle: string
if (target.mode === 'single') {
if (showingReport) {
subtitle = t('customers.smsModal.reportTitle')
} else if (target.mode === 'single') {
const phone = formatCellForDisplay(target.customer.cellNumber)
const name = [target.customer.firstName, target.customer.lastName]
.filter(Boolean)
@@ -169,6 +201,20 @@ export function SendCustomerSmsModal({
subtitle = t('customers.smsModal.subtitleAll')
}
const reportSent = bulkReport?.sentCount ?? 0
const reportFailed = bulkReport?.failedCount ?? bulkReport?.failures?.length ?? 0
const reportTotal = bulkReport?.recipientCount ?? 0
const reportSummary =
reportFailed === 0
? t('customers.smsModal.reportAllSent', { count: reportSent })
: reportSent === 0
? t('customers.smsModal.reportNone', { failed: reportFailed })
: t('customers.smsModal.reportPartial', {
sent: reportSent,
total: reportTotal,
failed: reportFailed,
})
return createPortal(
<div
className={`${modalStyles.overlay} ${closing ? modalStyles.overlayOut : modalStyles.overlayIn}`}
@@ -202,94 +248,125 @@ export function SendCustomerSmsModal({
</div>
<div className={modalStyles.body}>
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.field}>
<label htmlFor="customer-sms-template">{t('customers.smsModal.template')}</label>
<select
id="customer-sms-template"
value={templateId}
onChange={(e) => applyTemplate(e.target.value)}
disabled={isSubmitting || templatesLoading}
>
<option value="">{t('customers.smsModal.templateNone')}</option>
{templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
{tpl.isVerified ? ' ✓' : ''}
</option>
))}
</select>
{templatesLoading ? (
<p className={modalStyles.modalHint}>{t('customers.smsModal.templateLoading')}</p>
) : null}
{selectedTemplate ? (
<p className={modalStyles.modalHint}>
{selectedTemplate.isVerified
? t('customers.smsModal.templateHintVerified')
: t('customers.smsModal.templateHintPending')}
{showingReport ? (
<div className={modalStyles.smsReport}>
<p
className={`${modalStyles.smsReportSummary} ${
reportFailed === 0 ? modalStyles.smsReportSummaryOk : ''
}`}
>
{reportSummary}
</p>
) : null}
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<div
className={settingsStyles.smsVarChips}
role="group"
aria-label={t('settings.sms.variablesHint')}
>
{SMS_VARS.map((token) => (
<button
key={token}
type="button"
className={settingsStyles.smsVarChip}
onClick={() => insertVariable(token)}
disabled={
isSubmitting ||
Boolean(selectedTemplate?.isVerified) ||
message.length >= MAX_LEN
}
dir="ltr"
>
{token}
{reportFailed > 0 && (bulkReport?.failures?.length ?? 0) > 0 ? (
<ul className={modalStyles.smsReportFailures}>
{bulkReport!.failures!.map((item) => (
<li key={`${item.cellNumber}-${item.error}`} className={modalStyles.smsReportFailure}>
<span className={modalStyles.smsReportPhone}>
{formatCellForDisplay(item.cellNumber)}
</span>
<span className={modalStyles.smsReportError}>{item.error}</span>
</li>
))}
</ul>
) : null}
<div className={modalStyles.actions}>
<button type="button" className={modalStyles.submitBtn} onClick={onClose}>
{t('customers.smsModal.reportDone')}
</button>
))}
</div>
</div>
<textarea
id="customer-sms-message"
ref={messageRef}
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5}
disabled={isSubmitting || Boolean(selectedTemplate?.isVerified)}
placeholder={t('customers.smsModal.placeholder')}
dir={isFa ? 'rtl' : 'auto'}
readOnly={Boolean(selectedTemplate?.isVerified)}
/>
<p className={modalStyles.modalHint}>
{message.length} / {MAX_LEN}
</p>
</div>
) : (
<>
{error ? <p className={modalStyles.errorText}>{error}</p> : null}
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('customers.smsModal.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button>
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-template">{t('customers.smsModal.template')}</label>
<select
id="customer-sms-template"
value={templateId}
onChange={(e) => applyTemplate(e.target.value)}
disabled={isSubmitting || templatesLoading}
>
<option value="">{t('customers.smsModal.templateNone')}</option>
{templates.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name}
{tpl.isVerified ? ' ✓' : ''}
</option>
))}
</select>
{templatesLoading ? (
<p className={modalStyles.modalHint}>{t('customers.smsModal.templateLoading')}</p>
) : null}
{selectedTemplate ? (
<p className={modalStyles.modalHint}>
{selectedTemplate.isVerified
? t('customers.smsModal.templateHintVerified')
: t('customers.smsModal.templateHintPending')}
</p>
) : null}
</div>
<div className={modalStyles.field}>
<label htmlFor="customer-sms-message">{t('customers.smsModal.message')}</label>
<div
className={settingsStyles.smsVarChips}
role="group"
aria-label={t('settings.sms.variablesHint')}
>
{SMS_VARS.map((token) => (
<button
key={token}
type="button"
className={settingsStyles.smsVarChip}
onClick={() => insertVariable(token)}
disabled={
isSubmitting ||
Boolean(selectedTemplate?.isVerified) ||
message.length >= MAX_LEN
}
dir="ltr"
>
{token}
</button>
))}
</div>
<textarea
id="customer-sms-message"
ref={messageRef}
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, MAX_LEN))}
rows={5}
disabled={isSubmitting || Boolean(selectedTemplate?.isVerified)}
placeholder={t('customers.smsModal.placeholder')}
dir={isFa ? 'rtl' : 'auto'}
readOnly={Boolean(selectedTemplate?.isVerified)}
/>
<p className={modalStyles.modalHint}>
{message.length} / {MAX_LEN}
</p>
</div>
<div className={modalStyles.actions}>
<button
type="button"
className={modalStyles.cancelBtn}
onClick={onClose}
disabled={isSubmitting}
>
{t('customers.smsModal.cancel')}
</button>
<button
type="button"
className={modalStyles.submitBtn}
onClick={() => void handleSubmit()}
disabled={isSubmitting || !canSend}
>
{isSubmitting ? t('customers.smsModal.sending') : t('customers.smsModal.send')}
</button>
</div>
</>
)}
</div>
</div>
</div>,
@@ -0,0 +1,64 @@
.block {
display: flex;
flex-direction: column;
gap: 6px;
grid-column: 1 / -1;
}
.hint {
margin: 0 0 4px;
font-size: 12px;
line-height: 1.45;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.input,
.textarea {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.textarea {
min-height: 78px;
resize: vertical;
}
.input:focus,
.textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.input:disabled,
.textarea:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.counter {
margin: 0 0 8px;
font-size: 12px;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.counter:last-child {
margin-bottom: 0;
}
@@ -0,0 +1,69 @@
import styles from './SeoMetaFields.module.css'
const TITLE_MAX = 70
const DESCRIPTION_MAX = 160
interface SeoMetaFieldsProps {
seoMetaTitle: string
seoMetaDescription: string
onTitleChange: (value: string) => void
onDescriptionChange: (value: string) => void
disabled?: boolean
titleLabel: string
descriptionLabel: string
titlePlaceholder: string
descriptionPlaceholder: string
hint: string
}
/** Optional SEO overrides shared by content/category forms. */
export function SeoMetaFields({
seoMetaTitle,
seoMetaDescription,
onTitleChange,
onDescriptionChange,
disabled = false,
titleLabel,
descriptionLabel,
titlePlaceholder,
descriptionPlaceholder,
hint,
}: SeoMetaFieldsProps) {
return (
<div className={styles.block}>
<p className={styles.hint}>{hint}</p>
<label className={styles.label} htmlFor="seo-meta-title">
{titleLabel}
</label>
<input
id="seo-meta-title"
className={styles.input}
type="text"
value={seoMetaTitle}
disabled={disabled}
maxLength={TITLE_MAX}
placeholder={titlePlaceholder}
onChange={(e) => onTitleChange(e.target.value.slice(0, TITLE_MAX))}
/>
<p className={styles.counter}>
{seoMetaTitle.length} / {TITLE_MAX}
</p>
<label className={styles.label} htmlFor="seo-meta-description">
{descriptionLabel}
</label>
<textarea
id="seo-meta-description"
className={styles.textarea}
rows={3}
value={seoMetaDescription}
disabled={disabled}
maxLength={DESCRIPTION_MAX}
placeholder={descriptionPlaceholder}
onChange={(e) => onDescriptionChange(e.target.value.slice(0, DESCRIPTION_MAX))}
/>
<p className={styles.counter}>
{seoMetaDescription.length} / {DESCRIPTION_MAX}
</p>
</div>
)
}
@@ -519,6 +519,59 @@
color: #ef4444;
margin-bottom: 12px;
}
.smsReport {
display: flex;
flex-direction: column;
gap: 10px;
}
.smsReportSummary {
margin: 0;
font-size: var(--field-font-size);
color: var(--text-primary);
line-height: 1.5;
}
.smsReportSummaryOk {
color: var(--primary);
}
.smsReportFailures {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
max-height: min(40vh, 280px);
overflow-y: auto;
}
.smsReportFailure {
display: flex;
flex-direction: column;
gap: 2px;
padding: 8px 10px;
border-radius: var(--radius-sm);
border: 1px solid rgba(239, 68, 68, 0.25);
background: rgba(239, 68, 68, 0.06);
}
.smsReportPhone {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-ui);
direction: ltr;
text-align: start;
}
.smsReportError {
font-size: 12px;
color: #ef4444;
line-height: 1.4;
}
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
@@ -0,0 +1,278 @@
import { useEffect, useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
updateWebsiteSettings,
type WebsiteSchemaSettings,
type WebsiteSchemaType,
} from '../services/settingsService'
import { Tooltip } from './Tooltip'
import removeStyles from './VariationsModal.module.css'
import sharedStyles from '../pages/ProductSettingsPage.module.css'
import styles from '../pages/WebsiteSettingsPage.module.css'
const SCHEMA_TYPES: WebsiteSchemaType[] = ['Organization', 'LocalBusiness']
const EMPTY_SCHEMA: WebsiteSchemaSettings = {
enabled: true,
type: 'Organization',
name: null,
nameFa: null,
description: null,
priceRange: null,
sameAs: [],
}
interface WebsiteSchemaSettingsSectionProps {
initial: WebsiteSchemaSettings | null | undefined
disabled?: boolean
onSaved: (schema: WebsiteSchemaSettings) => void
}
export function WebsiteSchemaSettingsSection({
initial,
disabled = false,
onSaved,
}: WebsiteSchemaSettingsSectionProps) {
const t = useT()
const { showToast } = useToast()
const [draft, setDraft] = useState<WebsiteSchemaSettings>(initial ?? EMPTY_SCHEMA)
const [sameAsDraft, setSameAsDraft] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
setDraft(initial ?? EMPTY_SCHEMA)
setError('')
}, [initial])
function updateField<K extends keyof WebsiteSchemaSettings>(
key: K,
value: WebsiteSchemaSettings[K],
) {
setDraft((prev) => ({ ...prev, [key]: value }))
}
function addSameAs() {
const url = sameAsDraft.trim()
if (!url) return
if (!/^https?:\/\//i.test(url)) {
setError(t('website.settings.schema.sameAsInvalid'))
return
}
if (draft.sameAs.includes(url)) {
setSameAsDraft('')
return
}
updateField('sameAs', [...draft.sameAs, url].slice(0, 20))
setSameAsDraft('')
setError('')
}
function removeSameAs(url: string) {
updateField(
'sameAs',
draft.sameAs.filter((item) => item !== url),
)
}
async function handleSave() {
if (isSaving || disabled) return
setIsSaving(true)
setError('')
try {
const data = await updateWebsiteSettings({
schema: {
enabled: draft.enabled,
type: draft.type,
name: draft.name?.trim() || null,
nameFa: draft.nameFa?.trim() || null,
description: draft.description?.trim() || null,
priceRange: draft.priceRange?.trim() || null,
sameAs: draft.sameAs,
},
})
const saved = data.settings.website?.schema ?? draft
setDraft(saved)
onSaved(saved)
showToast(t('website.settings.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.settings.errorSave'))
}
} finally {
setIsSaving(false)
}
}
return (
<section className={`${sharedStyles.panel} ${styles.schemaPanel}`}>
<div className={styles.schemaHeader}>
<div>
<h2 className={styles.schemaTitle}>{t('website.settings.schema.title')}</h2>
<p className={styles.schemaDesc}>{t('website.settings.schema.desc')}</p>
</div>
<label className={styles.schemaToggle}>
<input
type="checkbox"
checked={draft.enabled}
disabled={disabled || isSaving}
onChange={(e) => updateField('enabled', e.target.checked)}
/>
<span>{t('website.settings.schema.enabled')}</span>
</label>
</div>
{error ? (
<div className={sharedStyles.alertError} role="alert">
{error}
</div>
) : null}
<div className={styles.schemaGrid}>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-type">{t('website.settings.schema.type')}</label>
<select
id="website-schema-type"
value={draft.type}
disabled={disabled || isSaving || !draft.enabled}
onChange={(e) => updateField('type', e.target.value as WebsiteSchemaType)}
>
{SCHEMA_TYPES.map((type) => (
<option key={type} value={type}>
{type === 'Organization'
? t('website.settings.schema.typeOrganization')
: t('website.settings.schema.typeLocalBusiness')}
</option>
))}
</select>
</div>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-name-fa">{t('website.settings.schema.nameFa')}</label>
<input
id="website-schema-name-fa"
type="text"
value={draft.nameFa ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.nameFaPlaceholder')}
onChange={(e) => updateField('nameFa', e.target.value || null)}
dir="rtl"
lang="fa"
/>
</div>
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-name">{t('website.settings.schema.name')}</label>
<input
id="website-schema-name"
type="text"
value={draft.name ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.namePlaceholder')}
onChange={(e) => updateField('name', e.target.value || null)}
dir="ltr"
/>
</div>
<div className={`${styles.field} ${styles.schemaField} ${styles.schemaFieldFull}`}>
<label htmlFor="website-schema-description">
{t('website.settings.schema.description')}
</label>
<textarea
id="website-schema-description"
rows={3}
value={draft.description ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.descriptionPlaceholder')}
onChange={(e) => updateField('description', e.target.value || null)}
/>
</div>
{draft.type === 'LocalBusiness' ? (
<div className={`${styles.field} ${styles.schemaField}`}>
<label htmlFor="website-schema-price-range">
{t('website.settings.schema.priceRange')}
</label>
<input
id="website-schema-price-range"
type="text"
value={draft.priceRange ?? ''}
disabled={disabled || isSaving || !draft.enabled}
placeholder={t('website.settings.schema.priceRangePlaceholder')}
onChange={(e) => updateField('priceRange', e.target.value || null)}
/>
</div>
) : null}
<div className={`${styles.field} ${styles.schemaField} ${styles.schemaFieldFull}`}>
<label htmlFor="website-schema-sameas">{t('website.settings.schema.sameAs')}</label>
<p className={styles.schemaHint}>{t('website.settings.schema.sameAsHint')}</p>
<div className={styles.sameAsAdd}>
<input
id="website-schema-sameas"
type="url"
value={sameAsDraft}
disabled={disabled || isSaving || !draft.enabled}
placeholder="https://…"
dir="ltr"
onChange={(e) => setSameAsDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
addSameAs()
}
}}
/>
<Tooltip label={t('website.settings.schema.sameAsAdd')}>
<button
type="button"
className={styles.addSameAsBtn}
onClick={addSameAs}
disabled={disabled || isSaving || !draft.enabled || !sameAsDraft.trim()}
aria-label={t('website.settings.schema.sameAsAdd')}
>
<Plus size={16} />
</button>
</Tooltip>
</div>
{draft.sameAs.length > 0 ? (
<ul className={styles.sameAsList}>
{draft.sameAs.map((url) => (
<li key={url} className={styles.sameAsRow}>
<span dir="ltr">{url}</span>
<Tooltip label={t('website.settings.schema.sameAsRemove')}>
<button
type="button"
className={removeStyles.removeRowBtn}
onClick={() => removeSameAs(url)}
disabled={disabled || isSaving || !draft.enabled}
aria-label={t('website.settings.schema.sameAsRemove')}
>
<Trash2 size={14} />
</button>
</Tooltip>
</li>
))}
</ul>
) : null}
</div>
</div>
<div className={styles.schemaActions}>
<button
type="button"
className={styles.saveSchemaBtn}
onClick={() => void handleSave()}
disabled={disabled || isSaving}
>
{isSaving ? t('website.settings.schema.saving') : t('website.settings.schema.save')}
</button>
</div>
</section>
)
}