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,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>
)
}