Add Website Tags & Badges management for custom meta and trust seals.

Business admins can manage head meta tags and eNamad/Samandehi footer HTML from the dashboard without hardcoding them in each storefront.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-10-03 18:07:17 +03:30
co-authored by Cursor
parent cb20fe3c40
commit 68b985dee3
6 changed files with 909 additions and 36 deletions
@@ -0,0 +1,171 @@
.overlay {
position: fixed;
inset: 0;
background: var(--modal-overlay-bg);
backdrop-filter: blur(var(--modal-overlay-blur, 16px));
-webkit-backdrop-filter: blur(var(--modal-overlay-blur, 16px));
display: flex;
align-items: center;
justify-content: center;
z-index: 260;
padding: 20px;
}
.modal {
width: 100%;
max-width: 640px;
background: var(--surface);
backdrop-filter: blur(24px);
-webkit-backdrop-filter: blur(24px);
border: 1px solid var(--glass-border);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(31, 38, 135, 0.15);
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24px 24px 0;
}
.title {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
font-family: var(--font-ui);
}
.closeBtn {
width: 36px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
color: var(--text-secondary);
transition: background 0.2s, color 0.2s;
}
.closeBtn:hover {
background: rgba(var(--primary-rgb) / 0.1);
color: var(--primary);
}
.form {
padding: 20px 24px 24px;
display: flex;
flex-direction: column;
gap: 14px;
}
.error {
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 13px;
color: #b91c1c;
background: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
font-family: var(--font-ui);
}
.field {
display: flex;
flex-direction: column;
gap: 6px;
}
.field label {
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.field input,
.field 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);
line-height: 1.4;
color: var(--text-primary);
background: var(--surface);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.field textarea {
min-height: 120px;
resize: vertical;
}
.field input:focus,
.field textarea:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.valueField {
width: 100%;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 10px;
padding-top: 4px;
}
.cancelBtn {
min-height: var(--field-height);
padding: 0 16px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-ui);
color: var(--text-secondary);
border-radius: var(--radius-sm);
transition: background 0.2s;
}
.cancelBtn:hover {
background: rgba(148, 163, 184, 0.15);
}
.submitBtn {
min-height: var(--field-height);
padding: 0 18px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-ui);
color: white;
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px rgba(var(--primary-rgb) / 0.3);
}
.overlayIn { animation: overlayFadeIn 0.22s ease forwards; }
.overlayOut { animation: overlayFadeOut 0.22s ease forwards; }
.modalIn { animation: modalFadeIn 0.25s ease forwards; }
.modalOut { animation: modalFadeOut 0.22s ease forwards; }
@keyframes overlayFadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes overlayFadeOut {
from { opacity: 1; }
to { opacity: 0; }
}
@keyframes modalFadeIn {
from { opacity: 0; transform: translateY(12px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes modalFadeOut {
from { opacity: 1; transform: translateY(0) scale(1); }
to { opacity: 0; transform: translateY(8px) scale(0.98); }
}
@@ -0,0 +1,152 @@
import { useEffect, useState } from 'react'
import { createPortal } from 'react-dom'
import { X } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui'
import { useT } from '../i18n/useT'
import styles from './WebsiteMetaTagModal.module.css'
const ANIMATION_MS = 220
const META_NAME_RE = /^[a-zA-Z0-9_.:-]{1,80}$/
interface WebsiteMetaTagModalProps {
open: boolean
onClose: () => void
onSubmit: (data: { name: string; content: string }) => void
initial?: { name: string; content: string } | null
}
export function WebsiteMetaTagModal({
open,
onClose,
onSubmit,
initial = null,
}: WebsiteMetaTagModalProps) {
const t = useT()
const { locale } = useLocale()
const isFa = locale === 'fa'
const isEditing = Boolean(initial)
const [mounted, setMounted] = useState(open)
const [closing, setClosing] = useState(false)
const [name, setName] = useState('')
const [content, setContent] = useState('')
const [formError, setFormError] = useState('')
useEffect(() => {
if (open) {
setMounted(true)
setClosing(false)
setName(initial?.name ?? '')
setContent(initial?.content ?? '')
setFormError('')
} else if (mounted) {
setClosing(true)
const timer = setTimeout(() => {
setMounted(false)
setClosing(false)
}, ANIMATION_MS)
return () => clearTimeout(timer)
}
// Reset fields only when opening; ignore `initial` identity churn while open.
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional
}, [open, mounted])
useEffect(() => {
if (!mounted || closing) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [mounted, closing, onClose])
if (!mounted) return null
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
const trimmedName = name.trim()
const trimmedContent = content.trim()
if (!trimmedName || !META_NAME_RE.test(trimmedName)) {
setFormError(t('website.badges.meta.nameInvalid'))
return
}
if (!trimmedContent) {
setFormError(t('website.badges.meta.contentRequired'))
return
}
onSubmit({ name: trimmedName, content: trimmedContent })
}
return createPortal(
<div
className={`${styles.overlay} ${closing ? styles.overlayOut : styles.overlayIn}`}
onClick={onClose}
>
<div
className={`${styles.modal} ${closing ? styles.modalOut : styles.modalIn}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="website-meta-tag-modal-title"
lang={isFa ? 'fa' : 'en'}
dir={isFa ? 'rtl' : 'ltr'}
>
<div className={styles.header}>
<h3 id="website-meta-tag-modal-title" className={styles.title}>
{isEditing ? t('website.badges.meta.editTitle') : t('website.badges.meta.modalTitle')}
</h3>
<button
type="button"
className={styles.closeBtn}
onClick={onClose}
aria-label={t('common.close')}
>
<X size={20} />
</button>
</div>
<form className={styles.form} onSubmit={handleSubmit}>
{formError ? (
<div className={styles.error} role="alert">
{formError}
</div>
) : null}
<div className={styles.field}>
<label htmlFor="website-meta-tag-name">{t('website.badges.meta.nameLabel')}</label>
<input
id="website-meta-tag-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('website.badges.meta.namePlaceholder')}
dir="ltr"
autoFocus
/>
</div>
<div className={`${styles.field} ${styles.valueField}`}>
<label htmlFor="website-meta-tag-value">{t('website.badges.meta.valueLabel')}</label>
<textarea
id="website-meta-tag-value"
rows={4}
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={t('website.badges.meta.contentPlaceholder')}
dir="ltr"
/>
</div>
<div className={styles.actions}>
<button type="button" className={styles.cancelBtn} onClick={onClose}>
{t('common.cancel')}
</button>
<button type="submit" className={styles.submitBtn}>
{isEditing ? t('website.badges.meta.saveEdit') : t('website.badges.meta.add')}
</button>
</div>
</form>
</div>
</div>,
document.body,
)
}
+70 -21
View File
@@ -88,7 +88,7 @@ const en = {
'nav.website.contact': 'Contact Us Form',
'nav.website.subscriptions': 'Subscriptions',
'nav.website.faq': 'FAQ',
'nav.website.badges': 'Badges',
'nav.website.badges': 'Tags & Badges',
'nav.website.settings': 'Settings',
'nav.website.ePayment': 'E-Payment',
'nav.help': 'Help Center',
@@ -2015,8 +2015,9 @@ const en = {
'website.card.subscriptions.link': 'Manage subscriptions',
'website.card.faq.desc': 'Create and organize frequently asked questions for your website.',
'website.card.faq.link': 'Manage FAQ',
'website.card.badges.desc': 'Show trust badges, certifications, and highlights on your website.',
'website.card.badges.link': 'Manage badges',
'website.card.badges.desc':
'Manage custom meta tags and trust badges (eNamad, Samandehi, and more) for your website.',
'website.card.badges.link': 'Manage tags & badges',
'website.card.settings.desc': 'Choose how special products are sourced for the website.',
'website.card.settings.link': 'Manage settings',
'settings.subtitle': 'Manage SMS balance and message templates.',
@@ -2427,13 +2428,36 @@ const en = {
'website.faq.feature.groups': 'Group items by category',
'website.faq.feature.publish': 'Publish or hide individual entries',
'website.badges.subtitle': 'Show trust badges and highlights on your public website.',
'website.badges.lead':
'Display certifications, guarantees, and trust signals to website visitors.',
'website.badges.note': 'Badge management will be connected here. Planned options include:',
'website.badges.feature.upload': 'Upload badge images with title and link',
'website.badges.feature.reorder': 'Reorder badges for homepage or footer display',
'website.badges.feature.toggle': 'Toggle visibility per badge',
'website.badges.subtitle': 'Manage custom meta tags and trust badges for your public website.',
'website.badges.save': 'Save tags & badges',
'website.badges.saving': 'Saving…',
'website.badges.saved': 'Tags & badges saved.',
'website.badges.errorLoad': 'Unable to load tags & badges.',
'website.badges.errorSave': 'Unable to save tags & badges.',
'website.badges.meta.title': 'Custom meta tags',
'website.badges.meta.desc': 'Each row is added to the website head as',
'website.badges.meta.sample': '<meta name="…" content="…">',
'website.badges.meta.add': 'Add meta tag',
'website.badges.meta.modalTitle': 'Add meta tag',
'website.badges.meta.editTitle': 'Edit meta tag',
'website.badges.meta.saveEdit': 'Save changes',
'website.badges.meta.edit': 'Edit meta tag',
'website.badges.meta.nameLabel': 'Name',
'website.badges.meta.valueLabel': 'Value',
'website.badges.meta.remove': 'Remove meta tag',
'website.badges.meta.empty': 'No custom tags yet. Use + to add a name and value.',
'website.badges.meta.namePlaceholder': 'name (e.g. google-site-verification)',
'website.badges.meta.contentPlaceholder': 'value / content',
'website.badges.meta.nameInvalid':
'Meta tag names may only use letters, numbers, and _ . : -',
'website.badges.meta.contentRequired': 'Each meta tag needs both a name and a value.',
'website.badges.enamad.title': 'eNamad',
'website.badges.enamad.desc':
'If you have received an eNamad badge for your website, enter its HTML code below to show it on the site.',
'website.badges.samandehi.title': 'Samandehi',
'website.badges.samandehi.desc':
'If you have received a Samandehi badge for your website, enter its HTML code below to show it on the site.',
'website.badges.trust.embedPlaceholder': '<a …><img …></a>',
'website.ePayment.subtitle': 'Configure online payment methods for your website checkout.',
'website.ePayment.master': 'Enable online payments',
@@ -2530,7 +2554,7 @@ const en = {
'title.contactForm': 'Contact Us Form',
'title.subscriptions': 'Subscriptions',
'title.faq': 'FAQ',
'title.badges': 'Badges',
'title.badges': 'Tags & Badges',
'title.ePayment': 'E-Payment',
'title.invoices': 'Invoices',
'title.finance': 'Finance Management',
@@ -2987,7 +3011,7 @@ const fa: Record<MessageKey, string> = {
'nav.website.contact': 'فرم تماس با ما',
'nav.website.subscriptions': 'عضویت‌ها',
'nav.website.faq': 'سوالات متداول',
'nav.website.badges': 'نشان‌ها',
'nav.website.badges': 'تگ و نشان‌ها',
'nav.website.settings': 'تنظیمات',
'nav.website.ePayment': 'پرداخت الکترونیک',
'nav.help': 'مرکز راهنما',
@@ -4910,8 +4934,9 @@ const fa: Record<MessageKey, string> = {
'website.card.subscriptions.link': 'مدیریت عضویت‌ها',
'website.card.faq.desc': 'سوالات متداول وب‌سایت را ایجاد و سازماندهی کنید.',
'website.card.faq.link': 'مدیریت سوالات متداول',
'website.card.badges.desc': 'نشان‌های اعتماد، گواهی‌ها و برجسته‌ها را در وب‌سایت نمایش دهید.',
'website.card.badges.link': 'مدیریت نشان‌ها',
'website.card.badges.desc':
'تگ‌های متا سفارشی و نشان‌های اعتماد (اینماد، ساماندهی و …) را برای وب‌سایت مدیریت کنید.',
'website.card.badges.link': 'مدیریت تگ و نشان‌ها',
'website.card.settings.desc': 'منبع خواندن محصولات ویژه وب‌سایت را انتخاب کنید.',
'website.card.settings.link': 'مدیریت تنظیمات',
'settings.subtitle': 'اعتبار پیامک و قالب‌های پیام را مدیریت کنید.',
@@ -5321,12 +5346,36 @@ const fa: Record<MessageKey, string> = {
'website.faq.feature.groups': 'گروه‌بندی موارد بر اساس دسته',
'website.faq.feature.publish': 'انتشار یا مخفی کردن هر مورد',
'website.badges.subtitle': 'نشان‌های اعتماد و برجسته‌ها را در وب‌سایت عمومی نمایش دهید.',
'website.badges.lead': 'گواهی‌ها، تضمین‌ها و نشانه‌های اعتماد را به بازدیدکنندگان نشان دهید.',
'website.badges.note': 'مدیریت نشان‌ها به‌زودی اینجا متصل می‌شود. گزینه‌های برنامه‌ریزی‌شده:',
'website.badges.feature.upload': 'آپلود تصویر نشان با عنوان و لینک',
'website.badges.feature.reorder': 'مرتب‌سازی نشان‌ها برای صفحه اصلی یا پاورقی',
'website.badges.feature.toggle': 'نمایش یا مخفی کردن هر نشان',
'website.badges.subtitle': 'تگ‌های متا سفارشی و نشان‌های اعتماد را برای وب‌سایت عمومی مدیریت کنید.',
'website.badges.save': 'ذخیره تگ و نشان‌ها',
'website.badges.saving': 'در حال ذخیره…',
'website.badges.saved': 'تگ و نشان‌ها ذخیره شد.',
'website.badges.errorLoad': 'بارگذاری تگ و نشان‌ها ممکن نشد.',
'website.badges.errorSave': 'ذخیره تگ و نشان‌ها ممکن نشد.',
'website.badges.meta.title': 'تگ‌های متا سفارشی',
'website.badges.meta.desc': 'هر ردیف در هد وب‌سایت قرار می‌گیرد به‌صورت',
'website.badges.meta.sample': '<meta name="…" content="…">',
'website.badges.meta.add': 'افزودن تگ متا',
'website.badges.meta.modalTitle': 'افزودن تگ متا',
'website.badges.meta.editTitle': 'ویرایش تگ متا',
'website.badges.meta.saveEdit': 'ذخیره تغییرات',
'website.badges.meta.edit': 'ویرایش تگ متا',
'website.badges.meta.nameLabel': 'نام',
'website.badges.meta.valueLabel': 'مقدار',
'website.badges.meta.remove': 'حذف تگ متا',
'website.badges.meta.empty': 'هنوز تگ سفارشی ندارید. با + نام و مقدار اضافه کنید.',
'website.badges.meta.namePlaceholder': 'نام (مثلاً google-site-verification)',
'website.badges.meta.contentPlaceholder': 'مقدار / محتوا',
'website.badges.meta.nameInvalid':
'نام تگ فقط می‌تواند شامل حروف، اعداد و _ . : - باشد.',
'website.badges.meta.contentRequired': 'هر تگ متا باید هم نام و هم مقدار داشته باشد.',
'website.badges.enamad.title': 'اینماد',
'website.badges.enamad.desc':
'در صورتی که نشان اینماد برای وبسایت خود دریافت کرده اید، کد HTML آن را در زیر وارد کنید تا در وبسایت نمایش داده شود.',
'website.badges.samandehi.title': 'ساماندهی',
'website.badges.samandehi.desc':
'در صورتی که نشان سامان دهی برای وبسایت خود دریافت کرده اید، کد HTML آن را در زیر وارد کنید تا در وبسایت نمایش داده شود.',
'website.badges.trust.embedPlaceholder': '<a …><img …></a>',
'website.ePayment.subtitle': 'روش‌های پرداخت آنلاین برای تسویه حساب وب‌سایت را پیکربندی کنید.',
'website.ePayment.master': 'فعال‌سازی پرداخت آنلاین',
@@ -5426,7 +5475,7 @@ const fa: Record<MessageKey, string> = {
'title.contactForm': 'فرم تماس با ما',
'title.subscriptions': 'عضویت‌ها',
'title.faq': 'سوالات متداول',
'title.badges': 'نشان‌ها',
'title.badges': 'تگ و نشان‌ها',
'title.ePayment': 'پرداخت الکترونیک',
'title.invoices': 'فاکتورها',
'title.finance': 'مدیریت مالی',
@@ -0,0 +1,123 @@
.emptyHint {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: var(--text-secondary);
font-family: var(--font-ui);
}
.metaDesc {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
max-width: none;
}
.oneLineHint {
max-width: none;
white-space: nowrap;
overflow-x: auto;
}
.metaSample {
display: inline-block;
margin: 0;
padding: 3px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.4;
color: var(--text-primary);
background: rgba(15, 23, 42, 0.55);
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
unicode-bidi: isolate;
white-space: nowrap;
}
.metaList {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
direction: ltr;
}
.metaRow {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 12px;
align-items: center;
padding: 10px 12px;
border-radius: var(--radius-sm);
background: rgba(148, 163, 184, 0.08);
border: 1px solid rgba(148, 163, 184, 0.2);
direction: ltr;
text-align: left;
}
.metaName {
min-width: 0;
max-width: 220px;
margin: 0;
padding: 4px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
background: rgba(15, 23, 42, 0.45);
border: 1px solid rgba(148, 163, 184, 0.25);
border-radius: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metaValue {
min-width: 0;
font-size: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.metaActions {
display: inline-flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.actions {
display: flex;
justify-content: flex-end;
margin-top: 8px;
margin-bottom: 24px;
}
@media (max-width: 768px) {
.metaRow {
grid-template-columns: minmax(0, 1fr) auto;
grid-template-areas:
'name actions'
'value actions';
}
.metaName {
grid-area: name;
max-width: none;
}
.metaValue {
grid-area: value;
}
.metaActions {
grid-area: actions;
flex-direction: column;
}
}
+366 -15
View File
@@ -1,23 +1,374 @@
import { WebsiteSectionShell } from '../components/WebsiteSectionShell'
import { useEffect, useState } from 'react'
import { Pencil, Plus, Trash2 } from 'lucide-react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { Tooltip } from '../components/Tooltip'
import { WebsiteMetaTagModal } from '../components/WebsiteMetaTagModal'
import { useToast } from '../context/ToastContext'
import type { BusinessMessageKey } from '../i18n/messages'
import { useT } from '../i18n/useT'
import styles from './WebsitePage.module.css'
import { ApiError } from '../lib/api'
import {
DEFAULT_WEBSITE_TAGS_AND_BADGES,
DEFAULT_WEBSITE_TRUST_BADGE,
getSettings,
updateWebsiteSettings,
type WebsiteMetaTag,
type WebsiteTagsAndBadgesSettings,
type WebsiteTrustBadgeSettings,
} from '../services/settingsService'
import controlStyles from '../components/CategoryRow.module.css'
import { createId } from '../utils/id'
import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css'
import settingsStyles from './WebsiteSettingsPage.module.css'
import styles from './WebsiteBadgesPage.module.css'
const META_NAME_RE = /^[a-zA-Z0-9_.:-]{1,80}$/
const MAX_META_TAGS = 40
type TrustKind = 'enamad' | 'samandehi'
function normalizeDraft(
value: WebsiteTagsAndBadgesSettings | null | undefined,
): WebsiteTagsAndBadgesSettings {
if (!value) {
return {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
}
}
return {
metaTags: Array.isArray(value.metaTags)
? value.metaTags.map((tag) => ({
id: tag.id || createId(),
name: tag.name ?? '',
content: tag.content ?? '',
}))
: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE, ...value.enamad },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE, ...value.samandehi },
}
}
export function WebsiteBadgesPage() {
const t = useT()
const { showToast } = useToast()
const [draft, setDraft] = useState<WebsiteTagsAndBadgesSettings>(
DEFAULT_WEBSITE_TAGS_AND_BADGES,
)
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [metaModalOpen, setMetaModalOpen] = useState(false)
const [editingMetaId, setEditingMetaId] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setDraft(normalizeDraft(data.settings.website?.tagsAndBadges))
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.badges.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
function openAddMetaModal() {
setEditingMetaId(null)
setMetaModalOpen(true)
}
function openEditMetaModal(id: string) {
setEditingMetaId(id)
setMetaModalOpen(true)
}
function closeMetaModal() {
setMetaModalOpen(false)
setEditingMetaId(null)
}
function saveMetaTag(data: { name: string; content: string }) {
setDraft((prev) => {
if (editingMetaId) {
return {
...prev,
metaTags: prev.metaTags.map((tag) =>
tag.id === editingMetaId
? { ...tag, name: data.name, content: data.content }
: tag,
),
}
}
if (prev.metaTags.length >= MAX_META_TAGS) return prev
const next: WebsiteMetaTag = {
id: createId(),
name: data.name,
content: data.content,
}
return { ...prev, metaTags: [...prev.metaTags, next] }
})
closeMetaModal()
setError('')
}
function removeMetaTag(id: string) {
setDraft((prev) => ({
...prev,
metaTags: prev.metaTags.filter((tag) => tag.id !== id),
}))
}
const editingMeta =
editingMetaId == null
? null
: (draft.metaTags.find((tag) => tag.id === editingMetaId) ?? null)
function updateTrust(kind: TrustKind, patch: Partial<WebsiteTrustBadgeSettings>) {
setDraft((prev) => ({
...prev,
[kind]: { ...prev[kind], ...patch },
}))
}
async function handleSave() {
if (isSaving || isLoading) return
for (const tag of draft.metaTags) {
const name = tag.name.trim()
const content = tag.content.trim()
if (!name && !content) continue
if (!name || !META_NAME_RE.test(name)) {
setError(t('website.badges.meta.nameInvalid'))
return
}
if (!content) {
setError(t('website.badges.meta.contentRequired'))
return
}
}
setIsSaving(true)
setError('')
try {
const payload: WebsiteTagsAndBadgesSettings = {
metaTags: draft.metaTags
.map((tag) => ({
id: tag.id,
name: tag.name.trim(),
content: tag.content.trim(),
}))
.filter((tag) => tag.name && tag.content),
enamad: {
embedHtml: draft.enamad.embedHtml.trim(),
},
samandehi: {
embedHtml: draft.samandehi.embedHtml.trim(),
},
}
const data = await updateWebsiteSettings({ tagsAndBadges: payload })
setDraft(normalizeDraft(data.settings.website?.tagsAndBadges ?? payload))
showToast(t('website.badges.saved'), 'success')
} catch (err) {
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.badges.errorSave'))
}
} finally {
setIsSaving(false)
}
}
return (
<WebsiteSectionShell
sectionLabel="Badges"
title={t('title.badges')}
subtitle={t('website.badges.subtitle')}
>
<p className={styles.placeholderLead}>{t('website.badges.lead')}</p>
<p className={styles.placeholderNote}>{t('website.badges.note')}</p>
<ul className={styles.featureList}>
<li>{t('website.badges.feature.upload')}</li>
<li>{t('website.badges.feature.reorder')}</li>
<li>{t('website.badges.feature.toggle')}</li>
</ul>
</WebsiteSectionShell>
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Badges' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<h2 className={pageStyles.pageTitle}>{t('title.badges')}</h2>
<p className={pageStyles.pageSubtitle}>{t('website.badges.subtitle')}</p>
</div>
</div>
{error ? (
<div className={sharedStyles.alertError} role="alert">
{error}
</div>
) : null}
<section className={`${sharedStyles.panel} ${settingsStyles.schemaPanel}`}>
<div className={settingsStyles.schemaHeader}>
<div>
<h2 className={settingsStyles.schemaTitle}>{t('website.badges.meta.title')}</h2>
<p className={`${settingsStyles.schemaDesc} ${styles.metaDesc}`}>
<span>{t('website.badges.meta.desc')}</span>{' '}
<code className={styles.metaSample} dir="ltr">
{t('website.badges.meta.sample')}
</code>
</p>
</div>
<Tooltip label={t('website.badges.meta.add')}>
<button
type="button"
className={settingsStyles.addSameAsBtn}
onClick={openAddMetaModal}
disabled={isLoading || isSaving || draft.metaTags.length >= MAX_META_TAGS}
aria-label={t('website.badges.meta.add')}
>
<Plus size={16} />
</button>
</Tooltip>
</div>
{draft.metaTags.length === 0 ? (
<p className={styles.emptyHint}>{t('website.badges.meta.empty')}</p>
) : (
<ul className={styles.metaList} dir="ltr">
{draft.metaTags.map((tag) => (
<li key={tag.id} className={styles.metaRow}>
<code className={styles.metaName}>{tag.name}</code>
<span className={styles.metaValue} title={tag.content}>
{tag.content}
</span>
<div className={styles.metaActions}>
<Tooltip label={t('website.badges.meta.edit')}>
<button
type="button"
className={controlStyles.controlBtn}
onClick={() => openEditMetaModal(tag.id)}
disabled={isLoading || isSaving}
aria-label={t('website.badges.meta.edit')}
>
<Pencil size={15} />
</button>
</Tooltip>
<Tooltip label={t('website.badges.meta.remove')}>
<button
type="button"
className={`${controlStyles.controlBtn} ${controlStyles.danger}`}
onClick={() => removeMetaTag(tag.id)}
disabled={isLoading || isSaving}
aria-label={t('website.badges.meta.remove')}
>
<Trash2 size={15} />
</button>
</Tooltip>
</div>
</li>
))}
</ul>
)}
</section>
<WebsiteMetaTagModal
open={metaModalOpen}
onClose={closeMetaModal}
onSubmit={saveMetaTag}
initial={
editingMeta
? { name: editingMeta.name, content: editingMeta.content }
: null
}
/>
<TrustBadgePanel
kind="enamad"
title={t('website.badges.enamad.title')}
desc={t('website.badges.enamad.desc')}
value={draft.enamad}
disabled={isLoading || isSaving}
onChange={(patch) => updateTrust('enamad', patch)}
t={t}
/>
<TrustBadgePanel
kind="samandehi"
title={t('website.badges.samandehi.title')}
desc={t('website.badges.samandehi.desc')}
value={draft.samandehi}
disabled={isLoading || isSaving}
onChange={(patch) => updateTrust('samandehi', patch)}
t={t}
/>
<div className={styles.actions}>
<button
type="button"
className={settingsStyles.saveSchemaBtn}
onClick={() => void handleSave()}
disabled={isLoading || isSaving}
>
{isSaving ? t('website.badges.saving') : t('website.badges.save')}
</button>
</div>
</main>
)
}
function TrustBadgePanel({
kind,
title,
desc,
value,
disabled,
onChange,
t,
}: {
kind: TrustKind
title: string
desc: string
value: WebsiteTrustBadgeSettings
disabled: boolean
onChange: (patch: Partial<WebsiteTrustBadgeSettings>) => void
t: (key: BusinessMessageKey) => string
}) {
const embedId = `trust-${kind}-embed`
return (
<section className={`${sharedStyles.panel} ${settingsStyles.schemaPanel}`}>
<div className={settingsStyles.schemaHeader}>
<div>
<h2 className={settingsStyles.schemaTitle}>{title}</h2>
<p className={`${settingsStyles.schemaDesc} ${styles.oneLineHint}`}>{desc}</p>
</div>
</div>
<div className={settingsStyles.schemaGrid}>
<div className={`${settingsStyles.field} ${settingsStyles.schemaField} ${settingsStyles.schemaFieldFull}`}>
<textarea
id={embedId}
rows={4}
value={value.embedHtml}
disabled={disabled}
placeholder={t('website.badges.trust.embedPlaceholder')}
aria-label={title}
dir="ltr"
onChange={(e) => onChange({ embedHtml: e.target.value })}
/>
</div>
</div>
</section>
)
}
@@ -111,6 +111,32 @@ export interface WebsiteSchemaSettings {
sameAs: string[]
}
export interface WebsiteMetaTag {
id: string
name: string
content: string
}
export interface WebsiteTrustBadgeSettings {
embedHtml: string
}
export interface WebsiteTagsAndBadgesSettings {
metaTags: WebsiteMetaTag[]
enamad: WebsiteTrustBadgeSettings
samandehi: WebsiteTrustBadgeSettings
}
export const DEFAULT_WEBSITE_TRUST_BADGE: WebsiteTrustBadgeSettings = {
embedHtml: '',
}
export const DEFAULT_WEBSITE_TAGS_AND_BADGES: WebsiteTagsAndBadgesSettings = {
metaTags: [],
enamad: { ...DEFAULT_WEBSITE_TRUST_BADGE },
samandehi: { ...DEFAULT_WEBSITE_TRUST_BADGE },
}
export interface WebsiteSitemapConfig {
baseUrl?: string | null
staticPages?: Array<{
@@ -126,6 +152,7 @@ export interface WebsiteSettings {
specialProductsSource: SpecialProductsSource
sitemapConfig?: WebsiteSitemapConfig | null
schema?: WebsiteSchemaSettings
tagsAndBadges?: WebsiteTagsAndBadgesSettings
}
export interface SmsSettings {