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,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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user