From 68b985dee3cf5338a8f4ebe5eb90730e257f5cd4 Mon Sep 17 00:00:00 2001 From: Alireza Hassani Date: Sat, 3 Oct 2026 18:07:17 +0330 Subject: [PATCH] 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 --- .../components/WebsiteMetaTagModal.module.css | 171 ++++++++ .../src/components/WebsiteMetaTagModal.tsx | 152 +++++++ apps/business/src/i18n/messages.ts | 91 ++++- .../src/pages/WebsiteBadgesPage.module.css | 123 ++++++ apps/business/src/pages/WebsiteBadgesPage.tsx | 381 +++++++++++++++++- apps/business/src/services/settingsService.ts | 27 ++ 6 files changed, 909 insertions(+), 36 deletions(-) create mode 100644 apps/business/src/components/WebsiteMetaTagModal.module.css create mode 100644 apps/business/src/components/WebsiteMetaTagModal.tsx create mode 100644 apps/business/src/pages/WebsiteBadgesPage.module.css diff --git a/apps/business/src/components/WebsiteMetaTagModal.module.css b/apps/business/src/components/WebsiteMetaTagModal.module.css new file mode 100644 index 0000000..f5736d7 --- /dev/null +++ b/apps/business/src/components/WebsiteMetaTagModal.module.css @@ -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); } +} diff --git a/apps/business/src/components/WebsiteMetaTagModal.tsx b/apps/business/src/components/WebsiteMetaTagModal.tsx new file mode 100644 index 0000000..c5f108d --- /dev/null +++ b/apps/business/src/components/WebsiteMetaTagModal.tsx @@ -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) { + 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( +
+
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-labelledby="website-meta-tag-modal-title" + lang={isFa ? 'fa' : 'en'} + dir={isFa ? 'rtl' : 'ltr'} + > +
+

+ {isEditing ? t('website.badges.meta.editTitle') : t('website.badges.meta.modalTitle')} +

+ +
+ +
+ {formError ? ( +
+ {formError} +
+ ) : null} + +
+ + setName(e.target.value)} + placeholder={t('website.badges.meta.namePlaceholder')} + dir="ltr" + autoFocus + /> +
+ +
+ +