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:
co-authored by
Cursor
parent
cb20fe3c40
commit
68b985dee3
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user