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
@@ -11,6 +11,7 @@ import { CitySearchSelect } from '../components/CitySearchSelect'
import { CategorySearchSelect } from '../components/CategorySearchSelect'
import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { useT } from '../i18n/useT'
import { translate } from '../i18n/messages'
import { ApiError, isAbortError } from '../lib/api'
@@ -34,6 +35,7 @@ import {
} from '../services/userProductsService'
import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import { seoMetaPayload } from '../utils/seoMeta'
import styles from './AddCustomerProductPage.module.css'
type StepId = 1 | 2 | 3
@@ -110,6 +112,8 @@ export function AddCustomerProductPage() {
const [titleFa, setTitleFa] = useState('')
const [titleEn, setTitleEn] = useState('')
const [description, setDescription] = useState('')
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [priceInput, setPriceInput] = useState('')
const [priceUnit, setPriceUnit] = useState<UserProductPriceCurrency>('IRT')
const [priceByExpert, setPriceByExpert] = useState(false)
@@ -207,6 +211,8 @@ export function AddCustomerProductPage() {
setTitleFa(product.titleFa ?? product.title ?? '')
setTitleEn(product.titleEn ?? '')
setDescription(product.description ?? '')
setSeoMetaTitle(product.seoMetaTitle ?? '')
setSeoMetaDescription(product.seoMetaDescription ?? '')
setPriceInput(
product.price != null ? formatIrtInput(String(product.price)) : '',
)
@@ -376,6 +382,7 @@ export function AddCustomerProductPage() {
titleFa: titleFa.trim(),
titleEn: titleEn.trim() || undefined,
description: description.trim() || undefined,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
categoryId,
price: price ?? undefined,
priceCurrency: priceUnit,
@@ -617,6 +624,19 @@ export function AddCustomerProductPage() {
/>
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={submitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
<div className={styles.priceRow}>
<label
className={`${styles.checkRow} ${styles.col6}`}
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { BlogPostType } from '../types/blog'
import { BLOG_TYPE_OPTIONS } from '../types/blog'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewBlogPage() {
const [titleImage, setTitleImage] = useState<string | null>(null)
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -99,6 +103,8 @@ export function AddNewBlogPage() {
setMainTextHtml(form.mainTextHtml)
setTitleImage(form.titleImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -134,6 +140,7 @@ export function AddNewBlogPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -317,6 +324,19 @@ export function AddNewBlogPage() {
<label>{t('blog.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/instruction'
import { VIDEO_PROVIDER_OPTIONS } from '../types/instruction'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -54,6 +56,8 @@ export function AddNewInstructionPage() {
const [coverImage, setCoverImage] = useState<string | null>(null)
const [coverImageAspect, setCoverImageAspect] = useState<CoverImageAspectId>('3:2')
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -103,6 +107,8 @@ export function AddNewInstructionPage() {
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -141,6 +147,7 @@ export function AddNewInstructionPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -360,6 +367,19 @@ export function AddNewInstructionPage() {
<label>{t('instruction.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -27,6 +28,7 @@ import {
} from '../services/portfolioService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -60,6 +62,8 @@ export function AddNewPortfolioPage() {
const [titleImageAspect, setTitleImageAspect] = useState<TitleImageAspectId>('3:2')
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
@@ -110,6 +114,8 @@ export function AddNewPortfolioPage() {
setTitleImage(form.titleImage)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
@@ -155,6 +161,7 @@ export function AddNewPortfolioPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -371,6 +378,19 @@ export function AddNewPortfolioPage() {
<label>{t('portfolio.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
@@ -6,6 +6,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { ProductFieldAiPromptModal } from '../components/ProductFieldAiPromptModal'
import { Tooltip } from '../components/Tooltip'
@@ -29,6 +30,7 @@ import {
} from '../services/productAiService'
import type { Category, FlatCategory } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import aiStyles from '../styles/ai.module.css'
import styles from './AddNewProductPage.module.css'
@@ -52,6 +54,8 @@ export function AddNewProductPage() {
const [thumbnail, setThumbnail] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [thumbnailMediaId, setThumbnailMediaId] = useState<string | null>(null)
const [galleryMediaIds, setGalleryMediaIds] = useState<string[]>([])
const [loaded, setLoaded] = useState(!isEdit)
@@ -171,6 +175,8 @@ export function AddNewProductPage() {
setThumbnail(form.thumbnail)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setThumbnailMediaId(form.thumbnailMediaId)
setGalleryMediaIds(form.galleryMediaIds)
setLoaded(true)
@@ -219,6 +225,7 @@ export function AddNewProductPage() {
featuredMediaId: featuredMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -438,6 +445,19 @@ export function AddNewProductPage() {
placeholder={t('products.form.tagsPlaceholder')}
/>
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
@@ -4,6 +4,7 @@ import { SearchableSelect } from '../components/SearchableSelect'
import { ImageCropper } from '../components/ImageCropper'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -26,6 +27,7 @@ import type { Category } from '../types/category'
import type { VideoProvider } from '../types/video'
import { VIDEO_PROVIDER_OPTIONS } from '../types/video'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -46,6 +48,8 @@ export function AddNewVideoPage() {
const [embedCode, setEmbedCode] = useState('')
const [coverImage, setCoverImage] = useState<string | null>(null)
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [featuredMediaId, setFeaturedMediaId] = useState<string | null>(null)
const [loaded, setLoaded] = useState(!isEdit)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -93,6 +97,8 @@ export function AddNewVideoPage() {
setEmbedCode(form.embedCode)
setCoverImage(form.coverImage)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setFeaturedMediaId(form.featuredMediaId)
setLoaded(true)
} catch (err) {
@@ -130,6 +136,7 @@ export function AddNewVideoPage() {
categoryId: categoryId || undefined,
featuredMediaId: nextFeaturedMediaId ?? undefined,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
status: 'published' as const,
}
@@ -322,6 +329,19 @@ export function AddNewVideoPage() {
<label>{t('video.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
@@ -5,6 +5,7 @@ import { ImageCropper } from '../components/ImageCropper'
import { ImageUploader } from '../components/ImageUploader'
import { TagInput } from '../components/TagInput'
import { RichTextEditor } from '../components/RichTextEditor'
import { SeoMetaFields } from '../components/SeoMetaFields'
import { JalaliDatePicker } from '../components/JalaliDatePicker'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { useToast } from '../context/ToastContext'
@@ -28,6 +29,7 @@ import {
} from '../services/workshopService'
import type { Category } from '../types/category'
import { flattenCategories } from '../utils/categories'
import { seoMetaPayload } from '../utils/seoMeta'
import pageStyles from '../components/PageContent.module.css'
import styles from './AddNewProductPage.module.css'
@@ -47,6 +49,8 @@ export function AddNewWorkshopPage() {
const [coverImage, setCoverImage] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [tags, setTags] = useState<string[]>([])
const [seoMetaTitle, setSeoMetaTitle] = useState('')
const [seoMetaDescription, setSeoMetaDescription] = useState('')
const [eventDate, setEventDate] = useState('')
const [eventTime, setEventTime] = useState('')
const [duration, setDuration] = useState('')
@@ -99,6 +103,8 @@ export function AddNewWorkshopPage() {
setCoverImage(form.coverImage)
setImages(form.images)
setTags(form.tags)
setSeoMetaTitle(form.seoMetaTitle)
setSeoMetaDescription(form.seoMetaDescription)
setEventDate(form.eventDate)
setEventTime(form.eventTime)
setDuration(form.duration)
@@ -166,6 +172,7 @@ export function AddNewWorkshopPage() {
featuredMediaId: nextFeaturedMediaId ?? undefined,
galleryMediaIds: nextGalleryMediaIds,
tags,
...seoMetaPayload(seoMetaTitle, seoMetaDescription),
eventDate: eventDate.trim() || null,
eventTime: eventTime.trim() || null,
duration: duration.trim() || null,
@@ -369,6 +376,19 @@ export function AddNewWorkshopPage() {
<label>{t('workshop.form.tags')}</label>
<TagInput tags={tags} onChange={setTags} />
</div>
<SeoMetaFields
seoMetaTitle={seoMetaTitle}
seoMetaDescription={seoMetaDescription}
onTitleChange={setSeoMetaTitle}
onDescriptionChange={setSeoMetaDescription}
disabled={isSubmitting}
hint={t('seo.meta.hint')}
titleLabel={t('seo.meta.title')}
descriptionLabel={t('seo.meta.description')}
titlePlaceholder={t('seo.meta.titlePlaceholder')}
descriptionPlaceholder={t('seo.meta.descriptionPlaceholder')}
/>
</div>
<div className={styles.actions}>
+12
View File
@@ -912,6 +912,18 @@ export function CustomersPage() {
onSent={(info) => {
if (info.mode === 'single' && info.phone) {
showToast(t('customers.toast.smsSent', { phone: info.phone }), 'success')
} else if (info.failedCount && info.failedCount > 0 && info.totalCount) {
showToast(
t('customers.toast.smsSentBulkPartial', {
sent: info.count,
total: info.totalCount,
failed: info.failedCount,
}),
'info',
)
if (info.mode === 'selected') {
setSelectedPeople(new Map())
}
} else {
showToast(t('customers.toast.smsSentBulk', { count: info.count }), 'success')
if (info.mode === 'selected') {
@@ -102,6 +102,194 @@
margin-top: 24px;
}
.schemaPanel {
margin-top: 24px;
}
.schemaHeader {
display: flex;
flex-wrap: wrap;
align-items: flex-start;
justify-content: space-between;
gap: 12px 16px;
margin-bottom: 16px;
}
.schemaTitle {
margin: 0;
font-size: 15px;
font-weight: 700;
color: var(--text-primary);
font-family: var(--font-ui);
}
.schemaDesc {
margin: 6px 0 0;
font-size: 12px;
line-height: 1.5;
color: var(--text-secondary);
max-width: 52ch;
}
.schemaToggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
cursor: pointer;
user-select: none;
}
.schemaToggle input {
width: 16px;
height: 16px;
accent-color: var(--primary);
}
.schemaGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 14px;
}
.schemaField {
min-width: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.schemaField label {
font-size: 12px;
font-weight: 600;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.schemaFieldFull {
grid-column: 1 / -1;
}
.schemaField input,
.schemaField textarea {
width: 100%;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
font-family: var(--font-ui);
color: var(--text-primary);
background-color: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.schemaField textarea {
min-height: 84px;
resize: vertical;
}
.schemaField input:focus,
.schemaField textarea:focus,
.schemaField select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.schemaHint {
margin: 0;
font-size: 12px;
color: var(--text-secondary);
line-height: 1.4;
}
.sameAsAdd {
display: flex;
gap: 8px;
align-items: center;
}
.sameAsAdd input {
flex: 1;
min-width: 0;
}
.addSameAsBtn {
width: var(--field-height);
height: var(--field-height);
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--primary);
background: rgba(var(--primary-rgb) / 0.08);
border: 1px solid rgba(var(--primary-rgb) / 0.22);
border-radius: var(--radius-sm);
cursor: pointer;
}
.addSameAsBtn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sameAsList {
list-style: none;
margin: 8px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.sameAsRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 6px 8px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.2);
font-size: 12px;
font-family: var(--font-ui);
color: var(--text-primary);
}
.sameAsRow span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.schemaActions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
.saveSchemaBtn {
min-height: var(--field-height);
padding: 0 16px;
font-size: var(--field-font-size);
font-family: var(--font-ui);
font-weight: 600;
color: #fff;
background: linear-gradient(135deg, var(--primary-glow) 0%, var(--primary) 55%, var(--primary-dark) 100%);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
}
.saveSchemaBtn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 768px) {
.field,
.sitemapActions {
@@ -116,6 +304,10 @@
.sitemapUrl {
max-width: none;
}
.schemaGrid {
grid-template-columns: 1fr;
}
}
.spin {
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { WebsiteSchemaSettingsSection } from '../components/WebsiteSchemaSettingsSection'
import { useTenantBranding } from '../context/TenantBrandingContext'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
@@ -11,6 +12,7 @@ import {
getSettings,
updateWebsiteSettings,
type SpecialProductsSource,
type WebsiteSchemaSettings,
type WebsiteSitemapConfig,
} from '../services/settingsService'
import { syncWebsiteSitemapConfig } from '../services/websiteSitemapService'
@@ -47,6 +49,7 @@ export function WebsiteSettingsPage() {
const storeModuleEnabled = hasBusinessModule(enabledModules, 'store')
const [source, setSource] = useState<SpecialProductsSource>('product')
const [sitemapConfig, setSitemapConfig] = useState<WebsiteSitemapConfig | null>(null)
const [schema, setSchema] = useState<WebsiteSchemaSettings | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [isSyncingSitemap, setIsSyncingSitemap] = useState(false)
@@ -69,6 +72,7 @@ export function WebsiteSettingsPage() {
defaultSpecialProductsSource(data.settings.modules?.enabled),
)
setSitemapConfig(data.settings.website?.sitemapConfig ?? null)
setSchema(data.settings.website?.schema ?? null)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
@@ -92,6 +96,9 @@ export function WebsiteSettingsPage() {
try {
const data = await updateWebsiteSettings({ specialProductsSource: next })
setSource(data.settings.website?.specialProductsSource ?? next)
if (data.settings.website?.schema) {
setSchema(data.settings.website.schema)
}
showToast(t('website.settings.saved'), 'success')
} catch (err) {
setSource(previous)
@@ -222,6 +229,14 @@ export function WebsiteSettingsPage() {
)}
</section>
{!isLoading ? (
<WebsiteSchemaSettingsSection
initial={schema}
disabled={isSaving}
onSaved={setSchema}
/>
) : null}
{storeModuleEnabled ? (
<div className={`${styles.ePaymentBlock} ${settingsStyles.stack}`}>
<EPaymentSettingsSection />