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
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user