x
This commit is contained in:
1 parent
602eaa9260
commit
c42dd4272f
30 files changed
+2024
-252
No files matched your search
@@ -12,6 +12,10 @@ export const DEFAULT_CONTENT_LANGUAGES: LanguageOption[] = [
|
||||
{ code: 'en', label: 'EN', nativeName: 'English', flag: '🇬🇧' },
|
||||
{ code: 'ja', label: 'JA', nativeName: '日本語', flag: '🇯🇵' },
|
||||
{ code: 'zh', label: 'ZH', nativeName: '简体中文', flag: '🇨🇳' },
|
||||
{ code: 'vi', label: 'VI', nativeName: 'Tiếng Việt', flag: '🇻🇳' },
|
||||
{ code: 'ko', label: 'KO', nativeName: '한국어', flag: '🇰🇷' },
|
||||
{ code: 'lo', label: 'LO', nativeName: 'ພາສາລາວ', flag: '🇱🇦' },
|
||||
{ code: 'it', label: 'IT', nativeName: 'Italiano', flag: '🇮🇹' },
|
||||
]
|
||||
|
||||
interface ContentLanguageTabsProps {
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Globe,
|
||||
Search,
|
||||
Share2,
|
||||
Sparkles,
|
||||
Image as ImageIcon,
|
||||
UploadCloud,
|
||||
CheckCircle2,
|
||||
Loader2,
|
||||
Save,
|
||||
Tag
|
||||
} from 'lucide-react';
|
||||
import { type Page, type PageTranslation } from '@homaroma/types';
|
||||
|
||||
interface PageSeoModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
page: Page | null;
|
||||
onUpdatePage: (updatedPage: Page) => void;
|
||||
onSave: () => Promise<void>;
|
||||
isSaving: boolean;
|
||||
onOpenMediaLibrary: () => void;
|
||||
frontendBaseUrl?: string;
|
||||
}
|
||||
|
||||
const SUPPORTED_LOCALES = [
|
||||
{ code: 'th', label: 'TH', full: 'ภาษาไทย (Default)' },
|
||||
{ code: 'en', label: 'EN', full: 'English' },
|
||||
{ code: 'ja', label: 'JA', full: '日本語' },
|
||||
{ code: 'zh', label: 'ZH', full: '中文' },
|
||||
];
|
||||
|
||||
export default function PageSeoModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
page,
|
||||
onUpdatePage,
|
||||
onSave,
|
||||
isSaving,
|
||||
onOpenMediaLibrary,
|
||||
frontendBaseUrl = 'https://homaroma.co.th',
|
||||
}: PageSeoModalProps) {
|
||||
const [activeLang, setActiveLang] = useState('th');
|
||||
const [activeTab, setActiveTab] = useState<'editor' | 'preview'>('editor');
|
||||
|
||||
if (!isOpen || !page) return null;
|
||||
|
||||
// Read current SEO value for specific language
|
||||
const getSeoValue = (field: 'seoTitle' | 'seoDescription' | 'seoKeywords'): string => {
|
||||
if (activeLang === 'th') {
|
||||
const thTrans = page.translations?.find((t) => t.langCode === 'th');
|
||||
return (thTrans?.[field] ?? page[field] ?? '') || '';
|
||||
}
|
||||
const trans = page.translations?.find((t) => t.langCode === activeLang);
|
||||
return trans?.[field] ?? '';
|
||||
};
|
||||
|
||||
// Update SEO value for specific language
|
||||
const handleUpdateField = (
|
||||
field: 'seoTitle' | 'seoDescription' | 'seoKeywords',
|
||||
value: string
|
||||
) => {
|
||||
const currentTranslations = [...(page.translations || [])];
|
||||
const transIndex = currentTranslations.findIndex((t) => t.langCode === activeLang);
|
||||
|
||||
if (transIndex >= 0) {
|
||||
currentTranslations[transIndex] = {
|
||||
...currentTranslations[transIndex],
|
||||
[field]: value || null,
|
||||
};
|
||||
} else {
|
||||
currentTranslations.push({
|
||||
id: 0,
|
||||
pageId: page.id,
|
||||
langCode: activeLang,
|
||||
title: page.title || '',
|
||||
[field]: value || null,
|
||||
} as PageTranslation);
|
||||
}
|
||||
|
||||
const updatedPage: Page = {
|
||||
...page,
|
||||
translations: currentTranslations,
|
||||
};
|
||||
|
||||
if (activeLang === 'th') {
|
||||
updatedPage[field] = value || null;
|
||||
}
|
||||
|
||||
onUpdatePage(updatedPage);
|
||||
};
|
||||
|
||||
const handleUpdateOgImage = (url: string) => {
|
||||
onUpdatePage({
|
||||
...page,
|
||||
ogImageUrl: url || null,
|
||||
});
|
||||
};
|
||||
|
||||
const currentTitle = getSeoValue('seoTitle');
|
||||
const currentDesc = getSeoValue('seoDescription');
|
||||
const currentKeywords = getSeoValue('seoKeywords');
|
||||
const currentOgImage = page.ogImageUrl || '';
|
||||
|
||||
const pageSlug = page.slug === 'home' ? '' : page.slug;
|
||||
const canonicalUrl = `${frontendBaseUrl}/${pageSlug}`;
|
||||
|
||||
// Title character count status (~50-60 chars)
|
||||
const titleLen = currentTitle.length;
|
||||
const getTitleStatus = () => {
|
||||
if (titleLen === 0) return { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 50-60 chars' };
|
||||
if (titleLen < 30) return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'Too short (optimal: 50-60 chars)' };
|
||||
if (titleLen <= 60) return { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' };
|
||||
return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'May be truncated (>60 chars)' };
|
||||
};
|
||||
|
||||
// Description character count status (~150-160 chars)
|
||||
const descLen = currentDesc.length;
|
||||
const getDescStatus = () => {
|
||||
if (descLen === 0) return { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 150-160 chars' };
|
||||
if (descLen < 100) return { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'Short snippet (optimal: 150-160 chars)' };
|
||||
if (descLen <= 160) return { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' };
|
||||
return { color: 'text-rose-400', badge: 'bg-rose-500/10 text-rose-400 border border-rose-500/20', msg: 'Too long, will truncate (>160 chars)' };
|
||||
};
|
||||
|
||||
const titleStatus = getTitleStatus();
|
||||
const descStatus = getDescStatus();
|
||||
|
||||
// Preview fallbacks
|
||||
const previewDisplayTitle = currentTitle || page.title || 'HomAroma';
|
||||
const previewDisplayDesc = currentDesc || 'Discover handcrafted pure essential oils, reed diffusers, and natural home fragrances by HomAroma.';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/70 backdrop-blur-md p-4 sm:p-6 overflow-y-auto">
|
||||
<div className="bg-night-900 border border-white/10 rounded-3xl shadow-2xl w-full max-w-4xl flex flex-col max-h-[92vh] overflow-hidden animate-pop-in text-slate-200">
|
||||
{/* Header */}
|
||||
<div className="px-8 py-5 border-b border-white/10 flex items-center justify-between bg-gradient-to-r from-cyan-950/20 via-white/[0.02] to-transparent shrink-0">
|
||||
<div className="flex items-center gap-3.5">
|
||||
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-2xl">
|
||||
<Globe className="w-5 h-5 text-cyan-glow" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h3 className="text-lg font-bold text-white tracking-wide">
|
||||
SEO & Social Meta Settings
|
||||
</h3>
|
||||
<span className="px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider bg-white/10 text-cyan-glow border border-cyan-glow/20">
|
||||
/{page.slug}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">
|
||||
Configure search engine tags, metadata, and social preview cards for "{page.title}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl hover:bg-white/10 text-slate-400 hover:text-white transition-all cursor-pointer"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Locale & View Tabs Subheader */}
|
||||
<div className="px-8 py-3 border-b border-white/5 bg-night-950/60 flex flex-wrap items-center justify-between gap-4 shrink-0">
|
||||
{/* Multi-language Selector */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest text-slate-500 mr-1">
|
||||
Language:
|
||||
</span>
|
||||
<div className="bg-black/40 p-1 rounded-xl flex gap-1 border border-white/5">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const trans = page.translations?.find((t) => t.langCode === locale.code);
|
||||
const hasCustomSeo = locale.code === 'th'
|
||||
? Boolean(page.seoTitle || page.seoDescription)
|
||||
: Boolean(trans?.seoTitle || trans?.seoDescription);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={locale.code}
|
||||
type="button"
|
||||
onClick={() => setActiveLang(locale.code)}
|
||||
className={`relative px-3.5 py-1.5 text-xs font-bold uppercase tracking-wider transition-all rounded-lg flex items-center gap-1.5 cursor-pointer ${
|
||||
activeLang === locale.code
|
||||
? 'bg-cyan-glow text-night-900 shadow-sm shadow-cyan-glow/20 font-extrabold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span>{locale.label}</span>
|
||||
{hasCustomSeo && (
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
activeLang === locale.code ? 'bg-night-900' : 'bg-emerald-400'
|
||||
}`}
|
||||
title="Custom SEO configured"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Tabs (Editor vs SERP Preview) */}
|
||||
<div className="flex items-center gap-1 bg-black/40 p-1 rounded-xl border border-white/5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('editor')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer ${
|
||||
activeTab === 'editor'
|
||||
? 'bg-white/10 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Form Fields</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveTab('preview')}
|
||||
className={`px-3 py-1 rounded-lg text-xs font-bold transition-all flex items-center gap-1.5 cursor-pointer ${
|
||||
activeTab === 'preview'
|
||||
? 'bg-white/10 text-white shadow-sm'
|
||||
: 'text-slate-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>SERP & Social Preview</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="flex-1 overflow-y-auto p-8 space-y-8">
|
||||
{activeTab === 'editor' ? (
|
||||
<div className="space-y-8">
|
||||
{/* Language Notice banner */}
|
||||
<div className="p-4 rounded-2xl bg-cyan-glow/[0.04] border border-cyan-glow/20 flex items-start gap-3">
|
||||
<Sparkles className="w-5 h-5 text-cyan-glow shrink-0 mt-0.5" />
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="text-slate-200 font-semibold">
|
||||
Editing SEO for locale:{' '}
|
||||
<span className="text-cyan-glow font-bold uppercase">
|
||||
{SUPPORTED_LOCALES.find((l) => l.code === activeLang)?.full}
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
Search engines will index these localized meta tags when visitors access the page in this language.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1. SEO Title */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2">
|
||||
<span>SEO Title Tag</span>
|
||||
<span className="text-slate-500 font-normal">({activeLang.toUpperCase()})</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-md ${titleStatus.badge}`}>
|
||||
{titleLen} / 60 chars • {titleStatus.msg}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentTitle}
|
||||
onChange={(e) => handleUpdateField('seoTitle', e.target.value)}
|
||||
placeholder={`${page.title} | HomAroma Aromatherapy & Essential Oils`}
|
||||
className="admin-input w-full font-medium"
|
||||
/>
|
||||
<div className="w-full bg-slate-800/80 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
titleLen <= 60 ? 'bg-cyan-glow' : 'bg-rose-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (titleLen / 60) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
The main title displayed on Google search results and browser tabs. Recommended ~50-60 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 2. SEO Description */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2">
|
||||
<span>Meta Description</span>
|
||||
<span className="text-slate-500 font-normal">({activeLang.toUpperCase()})</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-md ${descStatus.badge}`}>
|
||||
{descLen} / 160 chars • {descStatus.msg}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={currentDesc}
|
||||
onChange={(e) => handleUpdateField('seoDescription', e.target.value)}
|
||||
placeholder="Experience quiet luxury with HomAroma's premium botanical essential oils, reed diffusers, and handcrafted scent solutions..."
|
||||
className="admin-input w-full text-sm leading-relaxed"
|
||||
/>
|
||||
<div className="w-full bg-slate-800/80 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
descLen <= 160 ? 'bg-cyan-glow' : 'bg-rose-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (descLen / 160) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Concise summary displayed beneath the title in search engine results. Recommended ~150-160 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 3. SEO Keywords */}
|
||||
<div className="space-y-2.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2">
|
||||
<Tag className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Meta Keywords</span>
|
||||
<span className="text-slate-500 font-normal">({activeLang.toUpperCase()})</span>
|
||||
</label>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentKeywords}
|
||||
onChange={(e) => handleUpdateField('seoKeywords', e.target.value)}
|
||||
placeholder="essential oils, reed diffuser, aromatherapy, room spray, homaroma"
|
||||
className="admin-input w-full text-sm"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Comma-separated keywords for search engine categorizations and indexing tags.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 4. Social Share Image (OG Image) */}
|
||||
<div className="pt-4 border-t border-white/10 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2">
|
||||
<Share2 className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Social Share Image (og:image)</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Card preview image when shared on Facebook, LINE, Twitter, and messaging apps (Recommended 1200 × 630 px).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5 items-start">
|
||||
<div className="md:col-span-7 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={currentOgImage}
|
||||
onChange={(e) => handleUpdateOgImage(e.target.value)}
|
||||
placeholder="https://example.com/og-image.jpg or /images/hero.png"
|
||||
className="admin-input flex-1 text-xs"
|
||||
/>
|
||||
{currentOgImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleUpdateOgImage('')}
|
||||
className="px-3 py-2 bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 rounded-xl text-xs font-bold transition-all"
|
||||
title="Clear image"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onOpenMediaLibrary}
|
||||
className="w-full px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-cyan-glow/30 rounded-xl text-xs font-bold text-slate-200 hover:text-white flex items-center justify-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
<UploadCloud className="w-4 h-4 text-cyan-glow" />
|
||||
Choose from Media Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-5">
|
||||
<div className="relative aspect-[1.91/1] w-full rounded-2xl overflow-hidden border border-white/10 bg-black/40 flex items-center justify-center group shadow-inner">
|
||||
{currentOgImage ? (
|
||||
<>
|
||||
<img
|
||||
src={currentOgImage}
|
||||
alt="Social Share Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center p-2 text-center">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider bg-black/70 px-3 py-1.5 rounded-lg border border-white/20">
|
||||
OpenGraph 1200×630
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center p-4 space-y-1.5 text-slate-500">
|
||||
<ImageIcon className="w-8 h-8 mx-auto opacity-40" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider">No OG Image Set</p>
|
||||
<p className="text-[9px] text-slate-600">Will fallback to global site share image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Live Previews Tab */
|
||||
<div className="space-y-8">
|
||||
{/* Google Search Result Preview */}
|
||||
<div className="space-y-3 bg-night-950/60 p-6 rounded-3xl border border-white/10">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="w-4 h-4 text-cyan-glow" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-300">
|
||||
Google Search Result Snippet ({activeLang.toUpperCase()})
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">Desktop & Mobile Preview</span>
|
||||
</div>
|
||||
|
||||
<div className="p-5 bg-[#202124] rounded-2xl border border-white/5 font-sans space-y-1.5 text-left max-w-2xl">
|
||||
{/* Google Breadcrumb / URL */}
|
||||
<div className="flex items-center gap-2 text-[12px] text-[#bdc1c6] leading-none">
|
||||
<div className="w-4 h-4 rounded-full bg-cyan-glow/20 flex items-center justify-center text-[9px] text-cyan-glow font-bold">
|
||||
H
|
||||
</div>
|
||||
<span className="text-[#dadce0] font-medium">HomAroma</span>
|
||||
<span className="text-[#9aa0a6] text-[11px] font-mono truncate max-w-xs" title={canonicalUrl}>
|
||||
› {pageSlug || 'home'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Google Title */}
|
||||
<h5 className="text-[18px] text-[#8ab4f8] font-normal leading-snug cursor-pointer hover:underline">
|
||||
{previewDisplayTitle}
|
||||
</h5>
|
||||
|
||||
{/* Google Snippet */}
|
||||
<p className="text-[13px] text-[#bdc1c6] leading-relaxed line-clamp-2">
|
||||
{previewDisplayDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media Share Card Preview */}
|
||||
<div className="space-y-3 bg-night-950/60 p-6 rounded-3xl border border-white/10">
|
||||
<div className="flex items-center justify-between pb-3 border-b border-white/5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Share2 className="w-4 h-4 text-cyan-glow" />
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-300">
|
||||
Social Card Preview (Facebook, LINE, X, iMessage)
|
||||
</h4>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-500 font-mono">1.91 : 1 Aspect Ratio</span>
|
||||
</div>
|
||||
|
||||
<div className="max-w-md mx-auto rounded-2xl overflow-hidden border border-white/10 bg-[#18191a] shadow-xl text-left">
|
||||
<div className="aspect-[1.91/1] w-full bg-slate-900 relative overflow-hidden flex items-center justify-center">
|
||||
{currentOgImage ? (
|
||||
<img
|
||||
src={currentOgImage}
|
||||
alt="Social Share"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center text-slate-500 space-y-1">
|
||||
<ImageIcon className="w-10 h-10 opacity-30" />
|
||||
<span className="text-[11px] font-bold">HomAroma Default Banner</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 space-y-1 border-t border-white/5 bg-[#242526]">
|
||||
<p className="text-[10px] uppercase font-bold tracking-widest text-slate-400">
|
||||
HOMAROMA.CO.TH
|
||||
</p>
|
||||
<h5 className="text-sm font-bold text-slate-100 line-clamp-1">
|
||||
{previewDisplayTitle}
|
||||
</h5>
|
||||
<p className="text-xs text-slate-400 line-clamp-2 leading-relaxed">
|
||||
{previewDisplayDesc}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer actions */}
|
||||
<div className="px-8 py-5 border-t border-white/10 flex items-center justify-between bg-night-950/80 shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>Changes are stored in page state and synced on Save.</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 text-white text-xs font-bold uppercase tracking-wider transition-all cursor-pointer"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onSave();
|
||||
onClose();
|
||||
}}
|
||||
disabled={isSaving}
|
||||
className="px-6 py-2.5 rounded-xl bg-cyan-glow text-night-900 hover:bg-cyan-glow/90 shadow-lg shadow-cyan-glow/20 disabled:opacity-50 text-xs font-bold uppercase tracking-wider flex items-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4" />
|
||||
Save SEO Settings
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,11 +18,13 @@ import {
|
||||
ExternalLink,
|
||||
Copy,
|
||||
Check,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
Globe
|
||||
} from 'lucide-react';
|
||||
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
||||
import { getAdminDisplayString } from '../utils/localization';
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal';
|
||||
import PageSeoModal from '../components/page-builder/PageSeoModal';
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext';
|
||||
import { getAuthHeaders } from '../utils/auth';
|
||||
|
||||
@@ -100,17 +102,20 @@ export default function PageBuilderPage() {
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery' | 'tile'; index?: number } | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide' | 'gallery' | 'tile' | 'seoOgImage'; index?: number } | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
|
||||
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false);
|
||||
const [isSeoModalOpen, setIsSeoModalOpen] = useState(false);
|
||||
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
||||
|
||||
const handleMediaSelect = (url: string) => {
|
||||
if (uploadTarget?.type === 'tile' && typeof uploadTarget.index === 'number') {
|
||||
if (uploadTarget?.type === 'seoOgImage') {
|
||||
setPage(prev => (prev ? { ...prev, ogImageUrl: url } : prev));
|
||||
} else if (uploadTarget?.type === 'tile' && typeof uploadTarget.index === 'number') {
|
||||
const idx = uploadTarget.index;
|
||||
setEditingSection(prev => {
|
||||
if (!prev) return null;
|
||||
@@ -423,12 +428,22 @@ export default function PageBuilderPage() {
|
||||
id: page.id,
|
||||
slug: page.slug,
|
||||
title: page.title,
|
||||
isPublished: page.isPublished
|
||||
isPublished: page.isPublished,
|
||||
seoTitle: page.seoTitle ?? null,
|
||||
seoDescription: page.seoDescription ?? null,
|
||||
seoKeywords: page.seoKeywords ?? null,
|
||||
ogImageUrl: page.ogImageUrl ?? null,
|
||||
translations: page.translations || [],
|
||||
})
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok && data && !data.error) {
|
||||
setPage((prev) => (prev ? { ...prev, isPublished: data.isPublished ?? prev.isPublished } : prev));
|
||||
setPage((prev) => (prev ? {
|
||||
...prev,
|
||||
...(data.page || {}),
|
||||
isPublished: data.isPublished ?? data.page?.isPublished ?? prev.isPublished,
|
||||
translations: data.page?.translations ?? prev.translations,
|
||||
} : prev));
|
||||
await fetchPagesList();
|
||||
} else {
|
||||
alert(data.error || t('pageBuilder.errSaveSettings', 'Failed to save page settings'));
|
||||
@@ -1056,6 +1071,16 @@ export default function PageBuilderPage() {
|
||||
>
|
||||
<Plus className="w-4 h-4" /> {t('pageBuilder.createPageBtn', 'Create Page')}
|
||||
</button>
|
||||
{page && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSeoModalOpen(true)}
|
||||
className="px-4 py-2 bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow text-sm font-bold rounded-xl transition-all uppercase tracking-wider cursor-pointer flex items-center gap-2 shadow-sm"
|
||||
title={t('pageBuilder.seoMetaTooltip', 'Page SEO & Meta settings')}
|
||||
>
|
||||
<Globe className="w-4 h-4" /> {t('pageBuilder.seoMetaBtn', 'SEO / Meta')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1152,12 +1177,12 @@ export default function PageBuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row items-center gap-3 w-full">
|
||||
<div className="flex flex-col sm:flex-row items-center gap-2.5 w-full">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSavePageSettings}
|
||||
disabled={isSavingPageSettings}
|
||||
className="inline-flex flex-1 justify-center items-center gap-2 px-5 py-2 rounded-xl bg-white text-night-900 hover:bg-slate-200 disabled:opacity-50 text-xs font-bold uppercase tracking-wider transition-all shadow-md w-full sm:w-auto"
|
||||
className="inline-flex flex-1 justify-center items-center gap-2 px-4 py-2 rounded-xl bg-white text-night-900 hover:bg-slate-200 disabled:opacity-50 text-xs font-bold uppercase tracking-wider transition-all shadow-md w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
{isSavingPageSettings ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
@@ -1166,6 +1191,14 @@ export default function PageBuilderPage() {
|
||||
)}
|
||||
{t('pageBuilder.btnSaveSettings', 'Save Settings')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsSeoModalOpen(true)}
|
||||
className="inline-flex flex-1 justify-center items-center gap-2 px-4 py-2 rounded-xl bg-cyan-glow/10 hover:bg-cyan-glow/20 border border-cyan-glow/30 text-cyan-glow text-xs font-bold uppercase tracking-wider transition-all w-full sm:w-auto cursor-pointer"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
{t('pageBuilder.btnSeoSettings', 'SEO / Meta')}
|
||||
</button>
|
||||
<a
|
||||
href={getPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
@@ -1174,7 +1207,7 @@ export default function PageBuilderPage() {
|
||||
title={`Open ${page.title} live preview`}
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
{t('pageBuilder.btnPreviewLive', 'Preview Live')}
|
||||
{t('pageBuilder.btnPreviewLive', 'Preview')}
|
||||
</a>
|
||||
</div>
|
||||
{page.slug !== 'home' && (
|
||||
@@ -2049,6 +2082,20 @@ export default function PageBuilderPage() {
|
||||
onClose={() => setIsAddBlockModalOpen(false)}
|
||||
onSelectBlock={handleSelectBlockFromModal}
|
||||
/>
|
||||
|
||||
<PageSeoModal
|
||||
isOpen={isSeoModalOpen}
|
||||
onClose={() => setIsSeoModalOpen(false)}
|
||||
page={page}
|
||||
onUpdatePage={(updated) => setPage(updated)}
|
||||
onSave={handleSavePageSettings}
|
||||
isSaving={isSavingPageSettings}
|
||||
onOpenMediaLibrary={() => {
|
||||
setUploadTarget({ type: 'seoOgImage' });
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
frontendBaseUrl={frontendBase}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { API_BASE, type AttributeDefinition, type Category } from '@homaroma/typ
|
||||
import { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
||||
|
||||
const LANGUAGES = ['th', 'en', 'ja', 'zh'] as const
|
||||
const LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
|
||||
|
||||
type EditableDefinition = {
|
||||
|
||||
@@ -1,133 +1,581 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
||||
import { Save, Loader2 } from 'lucide-react'
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
||||
import { useState, useEffect } from 'react';
|
||||
import { API_BASE, type StoreSettings, type StoreSettingsTranslation } from '@homaroma/types';
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
Globe,
|
||||
Share2,
|
||||
Image as ImageIcon,
|
||||
UploadCloud,
|
||||
Sparkles,
|
||||
CheckCircle2
|
||||
} from 'lucide-react';
|
||||
import { useStoreSettings } from '../contexts/StoreSettingsContext';
|
||||
import MediaLibraryModal from '../components/MediaLibraryModal';
|
||||
|
||||
const SUPPORTED_LOCALES = [
|
||||
{ code: 'th', label: 'TH', full: 'ภาษาไทย (Default)' },
|
||||
{ code: 'en', label: 'EN', full: 'English' },
|
||||
{ code: 'ja', label: 'JA', full: '日本語' },
|
||||
{ code: 'zh', label: 'ZH', full: '中文' },
|
||||
{ code: 'vi', label: 'VI', full: 'Tiếng Việt' },
|
||||
{ code: 'ko', label: 'KO', full: '한국어' },
|
||||
{ code: 'lo', label: 'LO', full: 'ພາສາລາວ' },
|
||||
{ code: 'it', label: 'IT', full: 'Italiano' },
|
||||
];
|
||||
|
||||
export default function StoreSettingsPage() {
|
||||
const { updateSettingsState } = useStoreSettings()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const { updateSettingsState } = useStoreSettings();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
|
||||
const [brandName, setBrandName] = useState('')
|
||||
const [logoUrl, setLogoUrl] = useState('')
|
||||
const [themePreset, setThemePreset] = useState('default')
|
||||
const [facebookUrl, setFacebookUrl] = useState('')
|
||||
const [instagramUrl, setInstagramUrl] = useState('')
|
||||
const [tiktokUrl, setTiktokUrl] = useState('')
|
||||
// Store Brand & Identity
|
||||
const [brandName, setBrandName] = useState('');
|
||||
const [logoUrl, setLogoUrl] = useState('');
|
||||
const [themePreset, setThemePreset] = useState('default');
|
||||
const [facebookUrl, setFacebookUrl] = useState('');
|
||||
const [instagramUrl, setInstagramUrl] = useState('');
|
||||
const [tiktokUrl, setTiktokUrl] = useState('');
|
||||
|
||||
// Global Default SEO & Social Sharing
|
||||
const [defaultSeoTitle, setDefaultSeoTitle] = useState('');
|
||||
const [defaultSeoDescription, setDefaultSeoDescription] = useState('');
|
||||
const [defaultOgImage, setDefaultOgImage] = useState('');
|
||||
const [translations, setTranslations] = useState<StoreSettingsTranslation[]>([]);
|
||||
const [activeSeoLang, setActiveSeoLang] = useState('th');
|
||||
|
||||
// Media Library Modal
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
||||
const [mediaTarget, setMediaTarget] = useState<'logo' | 'ogImage' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(r => r.json())
|
||||
.then((r) => r.json())
|
||||
.then((data: StoreSettings) => {
|
||||
setBrandName(data.brandName || '')
|
||||
setLogoUrl(data.logoUrl || '')
|
||||
setThemePreset(data.themePreset || 'default')
|
||||
setFacebookUrl(data.facebookUrl || '')
|
||||
setInstagramUrl(data.instagramUrl || '')
|
||||
setTiktokUrl(data.tiktokUrl || '')
|
||||
setBrandName(data.brandName || '');
|
||||
setLogoUrl(data.logoUrl || '');
|
||||
setThemePreset(data.themePreset || 'default');
|
||||
setFacebookUrl(data.facebookUrl || '');
|
||||
setInstagramUrl(data.instagramUrl || '');
|
||||
setTiktokUrl(data.tiktokUrl || '');
|
||||
setDefaultSeoTitle(data.defaultSeoTitle || '');
|
||||
setDefaultSeoDescription(data.defaultSeoDescription || '');
|
||||
setDefaultOgImage(data.defaultOgImage || '');
|
||||
setTranslations(data.translations || []);
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const getLocalizedSeoValue = (field: 'defaultSeoTitle' | 'defaultSeoDescription'): string => {
|
||||
if (activeSeoLang === 'th') {
|
||||
const thTrans = translations.find((t) => t.langCode === 'th');
|
||||
if (field === 'defaultSeoTitle') return thTrans?.defaultSeoTitle ?? defaultSeoTitle;
|
||||
if (field === 'defaultSeoDescription') return thTrans?.defaultSeoDescription ?? defaultSeoDescription;
|
||||
}
|
||||
const trans = translations.find((t) => t.langCode === activeSeoLang);
|
||||
return trans?.[field] || '';
|
||||
};
|
||||
|
||||
const updateLocalizedSeoValue = (field: 'defaultSeoTitle' | 'defaultSeoDescription', value: string) => {
|
||||
const nextTranslations = [...translations];
|
||||
const idx = nextTranslations.findIndex((t) => t.langCode === activeSeoLang);
|
||||
|
||||
if (idx >= 0) {
|
||||
nextTranslations[idx] = {
|
||||
...nextTranslations[idx],
|
||||
[field]: value || null,
|
||||
};
|
||||
} else {
|
||||
nextTranslations.push({
|
||||
id: 0,
|
||||
storeSettingsId: 0,
|
||||
langCode: activeSeoLang,
|
||||
[field]: value || null,
|
||||
} as StoreSettingsTranslation);
|
||||
}
|
||||
setTranslations(nextTranslations);
|
||||
|
||||
if (activeSeoLang === 'th') {
|
||||
if (field === 'defaultSeoTitle') setDefaultSeoTitle(value);
|
||||
if (field === 'defaultSeoDescription') setDefaultSeoDescription(value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMediaSelect = (url: string) => {
|
||||
if (mediaTarget === 'logo') {
|
||||
setLogoUrl(url);
|
||||
} else if (mediaTarget === 'ogImage') {
|
||||
setDefaultOgImage(url);
|
||||
}
|
||||
setIsMediaModalOpen(false);
|
||||
};
|
||||
|
||||
const handleSave = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
e.preventDefault();
|
||||
setSaving(true);
|
||||
setSaveSuccess(false);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
brandName,
|
||||
logoUrl: logoUrl || null,
|
||||
themePreset,
|
||||
facebookUrl: facebookUrl || null,
|
||||
instagramUrl: instagramUrl || null,
|
||||
tiktokUrl: tiktokUrl || null,
|
||||
defaultSeoTitle: defaultSeoTitle || null,
|
||||
defaultSeoDescription: defaultSeoDescription || null,
|
||||
defaultOgImage: defaultOgImage || null,
|
||||
translations,
|
||||
};
|
||||
|
||||
const res = await fetch(`${API_BASE}/settings`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl }),
|
||||
})
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
updateSettingsState({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl })
|
||||
alert('Saved successfully!')
|
||||
updateSettingsState(payload);
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 3500);
|
||||
} else {
|
||||
alert('Failed to save settings');
|
||||
}
|
||||
} catch { alert('Failed to save') } finally { setSaving(false) }
|
||||
} catch (err) {
|
||||
console.error('Save store settings error:', err);
|
||||
alert('Failed to save settings');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const currentTitle = getLocalizedSeoValue('defaultSeoTitle');
|
||||
const currentDesc = getLocalizedSeoValue('defaultSeoDescription');
|
||||
|
||||
const titleLen = currentTitle.length;
|
||||
const descLen = currentDesc.length;
|
||||
|
||||
const titleStatus =
|
||||
titleLen === 0
|
||||
? { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 50-60 chars' }
|
||||
: titleLen <= 60
|
||||
? { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' }
|
||||
: { color: 'text-amber-400', badge: 'bg-amber-500/10 text-amber-400 border border-amber-500/20', msg: 'May truncate (>60 chars)' };
|
||||
|
||||
const descStatus =
|
||||
descLen === 0
|
||||
? { color: 'text-slate-400', badge: 'bg-slate-800 text-slate-400', msg: 'Recommended: 150-160 chars' }
|
||||
: descLen <= 160
|
||||
? { color: 'text-emerald-400', badge: 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20', msg: 'Ideal length' }
|
||||
: { color: 'text-rose-400', badge: 'bg-rose-500/10 text-rose-400 border border-rose-500/20', msg: 'Too long (>160 chars)' };
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-20">
|
||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center p-20"><Loader2 className="w-8 h-8 text-cyan-glow animate-spin" /></div>
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Store Settings</h1>
|
||||
<p className="text-slate-400 text-sm">Brand identity, logo, theme preset and social media links.</p>
|
||||
<div className="max-w-4xl space-y-8 animate-fade-in text-slate-100 pb-16">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<h1 className="text-3xl font-bold">Store Settings</h1>
|
||||
<p className="text-slate-400 text-sm">
|
||||
Brand identity, global default SEO metadata, theme preset, and social links.
|
||||
</p>
|
||||
</div>
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 rounded-xl text-xs font-bold animate-fade-in">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>Store settings saved successfully!</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-8">
|
||||
{/* Brand Identity */}
|
||||
{/* 1. Brand Identity */}
|
||||
<div className="admin-card p-8 space-y-6">
|
||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg"><span className="text-xl">✨</span></div>
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand Identity</h2>
|
||||
<div className="p-2 bg-purple-500/10 rounded-lg">
|
||||
<span className="text-xl">✨</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
||||
Brand Identity
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">Core store branding and logo representation.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Brand Name</label>
|
||||
<input type="text" required value={brandName} onChange={e => setBrandName(e.target.value)}
|
||||
className="admin-input w-full max-w-sm" placeholder="Enter Brand Name" />
|
||||
<p className="text-[10px] text-slate-400">Used in header, footer, and email communications.</p>
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
Brand Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={brandName}
|
||||
onChange={(e) => setBrandName(e.target.value)}
|
||||
className="admin-input w-full max-w-md"
|
||||
placeholder="HomAroma"
|
||||
/>
|
||||
<p className="text-[11px] text-slate-400">Used in header, footer, emails, and default metadata.</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Logo URL</label>
|
||||
<input type="url" value={logoUrl} onChange={e => setLogoUrl(e.target.value)}
|
||||
className="admin-input w-full max-w-sm" placeholder="https://example.com/logo.png" />
|
||||
<p className="text-[10px] text-slate-400">If provided, replaces the brand name text in headers.</p>
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
Logo URL
|
||||
</label>
|
||||
<div className="flex gap-2 max-w-md">
|
||||
<input
|
||||
type="url"
|
||||
value={logoUrl}
|
||||
onChange={(e) => setLogoUrl(e.target.value)}
|
||||
className="admin-input flex-1"
|
||||
placeholder="https://example.com/logo.png or /images/logo.png"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMediaTarget('logo');
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
className="px-4 py-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-xs font-bold text-slate-200 hover:text-white transition-all cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<UploadCloud className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
Media
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">If provided, replaces the brand name text in website headers.</p>
|
||||
{logoUrl && (
|
||||
<div className="mt-2 p-2 bg-black/20 rounded-md inline-block">
|
||||
<div className="mt-2 p-3 bg-black/40 border border-white/5 rounded-xl inline-block">
|
||||
<img src={logoUrl} alt="Logo Preview" className="h-8 object-contain" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Theme Preset</label>
|
||||
<select value={themePreset} onChange={e => setThemePreset(e.target.value)}
|
||||
className="admin-input w-full max-w-sm bg-slate-900 border-white/10">
|
||||
<option value="default">Default</option>
|
||||
<option value="warm">Warm</option>
|
||||
<option value="minimal">Minimal</option>
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
Theme Preset
|
||||
</label>
|
||||
<select
|
||||
value={themePreset}
|
||||
onChange={(e) => setThemePreset(e.target.value)}
|
||||
className="admin-input w-full max-w-md bg-slate-900 border-white/10 cursor-pointer"
|
||||
>
|
||||
<option value="default">Default (Aroma Botanical)</option>
|
||||
<option value="warm">Warm Amber</option>
|
||||
<option value="minimal">Minimal Modern</option>
|
||||
</select>
|
||||
<p className="text-[10px] text-slate-400">Starting palette before custom theme tokens are applied.</p>
|
||||
<p className="text-[11px] text-slate-400">Starting palette before custom theme tokens are applied.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Social Media */}
|
||||
{/* 2. Global Default SEO & Social Sharing */}
|
||||
<div className="admin-card p-8 space-y-6">
|
||||
<div className="flex items-center justify-between border-b border-white/10 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-cyan-glow/10 border border-cyan-glow/20 rounded-xl">
|
||||
<Globe className="w-5 h-5 text-cyan-glow" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
||||
Global SEO & Social Sharing Defaults
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">
|
||||
Default title template, description, and social share image applied across the website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Tabs for Global SEO */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3">
|
||||
<span className="text-[11px] font-bold uppercase tracking-widest text-slate-400 shrink-0">
|
||||
SEO Language:
|
||||
</span>
|
||||
<div className="bg-black/40 p-1 rounded-xl flex flex-wrap gap-1 border border-white/5">
|
||||
{SUPPORTED_LOCALES.map((locale) => {
|
||||
const trans = translations.find((t) => t.langCode === locale.code);
|
||||
const hasCustom =
|
||||
locale.code === 'th'
|
||||
? Boolean(defaultSeoTitle || defaultSeoDescription)
|
||||
: Boolean(trans?.defaultSeoTitle || trans?.defaultSeoDescription);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={locale.code}
|
||||
type="button"
|
||||
onClick={() => setActiveSeoLang(locale.code)}
|
||||
className={`relative px-3.5 py-1.5 text-xs font-bold uppercase tracking-wider transition-all rounded-lg flex items-center gap-1.5 cursor-pointer ${
|
||||
activeSeoLang === locale.code
|
||||
? 'bg-cyan-glow text-night-900 shadow-sm font-extrabold'
|
||||
: 'text-slate-400 hover:text-white hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<span>{locale.label}</span>
|
||||
{hasCustom && (
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
activeSeoLang === locale.code ? 'bg-night-900' : 'bg-emerald-400'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Language Note */}
|
||||
<div className="p-3.5 rounded-xl bg-white/[0.02] border border-white/5 flex items-center gap-2.5 text-xs text-slate-300">
|
||||
<Sparkles className="w-4 h-4 text-cyan-glow shrink-0" />
|
||||
<span>
|
||||
Editing fallback SEO for{' '}
|
||||
<strong className="text-cyan-glow uppercase">
|
||||
{SUPPORTED_LOCALES.find((l) => l.code === activeSeoLang)?.full}
|
||||
</strong>
|
||||
. Pages without customized SEO will use these defaults.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Default SEO Title */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-300 flex items-center gap-2">
|
||||
<span>Default SEO Title Template / Fallback</span>
|
||||
<span className="text-slate-500 font-normal">({activeSeoLang.toUpperCase()})</span>
|
||||
</label>
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-md ${titleStatus.badge}`}>
|
||||
{titleLen} / 60 chars • {titleStatus.msg}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={currentTitle}
|
||||
onChange={(e) => updateLocalizedSeoValue('defaultSeoTitle', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="HomAroma | Pure Essential Oils & Botanical Aromatherapy"
|
||||
/>
|
||||
<div className="w-full bg-slate-800/80 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
titleLen <= 60 ? 'bg-cyan-glow' : 'bg-rose-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (titleLen / 60) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Fallback page title on search engine results when specific page SEO title is not set.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default SEO Description */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-300 flex items-center gap-2">
|
||||
<span>Default SEO Meta Description</span>
|
||||
<span className="text-slate-500 font-normal">({activeSeoLang.toUpperCase()})</span>
|
||||
</label>
|
||||
<span className={`text-[11px] font-bold px-2 py-0.5 rounded-md ${descStatus.badge}`}>
|
||||
{descLen} / 160 chars • {descStatus.msg}
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={currentDesc}
|
||||
onChange={(e) => updateLocalizedSeoValue('defaultSeoDescription', e.target.value)}
|
||||
className="admin-input w-full text-sm leading-relaxed"
|
||||
placeholder="Hand-blended botanical fragrances and quiet luxury essential oils crafted for serene modern living. Free carbon-neutral delivery on eligible orders."
|
||||
/>
|
||||
<div className="w-full bg-slate-800/80 h-1 rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
descLen <= 160 ? 'bg-cyan-glow' : 'bg-rose-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (descLen / 160) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11px] text-slate-400">
|
||||
Fallback snippet displayed beneath titles on Google search results. Recommended ~150-160 characters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default Social Share Image (OG Image) */}
|
||||
<div className="pt-4 border-t border-white/10 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-slate-300 flex items-center gap-2">
|
||||
<Share2 className="w-3.5 h-3.5 text-cyan-glow" />
|
||||
<span>Default Social Share Image (og:image)</span>
|
||||
</label>
|
||||
<p className="text-[11px] text-slate-400 mt-0.5">
|
||||
Default banner shown on social media shares (Facebook, LINE, X, iMessage) across all pages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-12 gap-5 items-start">
|
||||
<div className="md:col-span-7 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={defaultOgImage}
|
||||
onChange={(e) => setDefaultOgImage(e.target.value)}
|
||||
placeholder="https://example.com/og-image.jpg or /images/hero.png"
|
||||
className="admin-input flex-1 text-xs"
|
||||
/>
|
||||
{defaultOgImage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDefaultOgImage('')}
|
||||
className="px-3 py-2 bg-white/5 hover:bg-rose-500/20 text-slate-400 hover:text-rose-300 border border-white/10 rounded-xl text-xs font-bold transition-all"
|
||||
title="Clear image"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMediaTarget('ogImage');
|
||||
setIsMediaModalOpen(true);
|
||||
}}
|
||||
className="w-full px-4 py-2.5 bg-white/5 hover:bg-white/10 border border-white/10 hover:border-cyan-glow/30 rounded-xl text-xs font-bold text-slate-200 hover:text-white flex items-center justify-center gap-2 transition-all cursor-pointer"
|
||||
>
|
||||
<UploadCloud className="w-4 h-4 text-cyan-glow" />
|
||||
Choose Default OG Image from Media Library
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="md:col-span-5">
|
||||
<div className="relative aspect-[1.91/1] w-full rounded-2xl overflow-hidden border border-white/10 bg-black/40 flex items-center justify-center group shadow-inner">
|
||||
{defaultOgImage ? (
|
||||
<>
|
||||
<img
|
||||
src={defaultOgImage}
|
||||
alt="Default Social Share Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center p-2 text-center">
|
||||
<span className="text-[10px] font-bold text-white uppercase tracking-wider bg-black/70 px-3 py-1.5 rounded-lg border border-white/20">
|
||||
Default OpenGraph 1200×630
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center p-4 space-y-1 text-slate-500">
|
||||
<ImageIcon className="w-7 h-7 mx-auto opacity-40" />
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider">No Default OG Image</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Google SERP Preview Box */}
|
||||
<div className="mt-4 p-5 bg-[#202124] rounded-2xl border border-white/5 font-sans space-y-1.5 text-left max-w-2xl">
|
||||
<div className="flex items-center gap-2 text-[11px] text-[#bdc1c6]">
|
||||
<div className="w-3.5 h-3.5 rounded-full bg-cyan-glow/20 flex items-center justify-center text-[8px] text-cyan-glow font-bold">
|
||||
H
|
||||
</div>
|
||||
<span className="text-[#dadce0] font-medium">{brandName || 'HomAroma'}</span>
|
||||
<span className="text-[#9aa0a6] text-[10px] font-mono">› (Global Fallback)</span>
|
||||
</div>
|
||||
<h5 className="text-[16px] text-[#8ab4f8] font-normal leading-snug">
|
||||
{currentTitle || `${brandName || 'HomAroma'} | Pure Essential Oils`}
|
||||
</h5>
|
||||
<p className="text-[12px] text-[#bdc1c6] leading-relaxed line-clamp-2">
|
||||
{currentDesc || 'Hand-blended botanical fragrances and quiet luxury essential oils crafted for serene modern living.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Social Media Links */}
|
||||
<div className="admin-card p-8 space-y-6">
|
||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
||||
<div className="p-2 bg-cyan-glow/10 rounded-lg"><span className="text-xl">🔗</span></div>
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Social Media Links</h2>
|
||||
<div className="p-2 bg-cyan-glow/10 rounded-lg">
|
||||
<span className="text-xl">🔗</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">
|
||||
Social Media Links
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400">Official social channels linked in headers and footers.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-3 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Facebook URL</label>
|
||||
<input type="text" value={facebookUrl} onChange={e => setFacebookUrl(e.target.value)}
|
||||
className="admin-input w-full" placeholder="https://facebook.com/..." />
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
Facebook URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={facebookUrl}
|
||||
onChange={(e) => setFacebookUrl(e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="https://facebook.com/homaroma"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Instagram URL</label>
|
||||
<input type="text" value={instagramUrl} onChange={e => setInstagramUrl(e.target.value)}
|
||||
className="admin-input w-full" placeholder="https://instagram.com/..." />
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
Instagram URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={instagramUrl}
|
||||
onChange={(e) => setInstagramUrl(e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="https://instagram.com/homaroma"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">TikTok URL</label>
|
||||
<input type="text" value={tiktokUrl} onChange={e => setTiktokUrl(e.target.value)}
|
||||
className="admin-input w-full" placeholder="https://tiktok.com/@..." />
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||
TikTok URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tiktokUrl}
|
||||
onChange={(e) => setTiktokUrl(e.target.value)}
|
||||
className="admin-input w-full text-xs"
|
||||
placeholder="https://tiktok.com/@homaroma"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={saving}
|
||||
className="btn-primary py-4 px-12 flex items-center gap-2 ml-auto text-sm">
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Store Settings
|
||||
</button>
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-xs text-slate-400">
|
||||
Clicking Save will update all store configuration, brand identity, and global SEO metadata.
|
||||
</p>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="btn-primary py-3.5 px-10 flex items-center gap-2 text-sm font-bold shadow-lg shadow-cyan-glow/20 cursor-pointer"
|
||||
>
|
||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
||||
Save Store Settings
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<MediaLibraryModal
|
||||
isOpen={isMediaModalOpen}
|
||||
onClose={() => setIsMediaModalOpen(false)}
|
||||
onSelect={handleMediaSelect}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import { getAuthHeaders } from '../../utils/auth'
|
||||
|
||||
type SaveState = { type: 'success' | 'error'; message: string } | null
|
||||
|
||||
const FOOTER_LANGUAGES = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'] as const
|
||||
type FooterLanguage = typeof FOOTER_LANGUAGES[number]
|
||||
|
||||
export default function FooterBrandPage() {
|
||||
const { updateSettingsState } = useStoreSettings()
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -17,8 +20,8 @@ export default function FooterBrandPage() {
|
||||
const [facebookUrl, setFacebookUrl] = useState('')
|
||||
const [instagramUrl, setInstagramUrl] = useState('')
|
||||
const [tiktokUrl, setTiktokUrl] = useState('')
|
||||
const [description, setDescription] = useState<Record<string, string>>({ th: '', en: '' })
|
||||
const [activeLanguage, setActiveLanguage] = useState<'th' | 'en'>('th')
|
||||
const [description, setDescription] = useState<Record<string, string>>({ th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' })
|
||||
const [activeLanguage, setActiveLanguage] = useState<FooterLanguage>('th')
|
||||
const [version, setVersion] = useState<string | undefined>()
|
||||
|
||||
useEffect(() => {
|
||||
@@ -102,7 +105,31 @@ export default function FooterBrandPage() {
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-brand-name" className="text-xs font-bold uppercase tracking-widest text-slate-400">Brand name</label><input id="footer-brand-name" required value={brandName} onChange={event => setBrandName(event.target.value)} className="admin-input w-full" placeholder="Enter Brand Name" /></div>
|
||||
<div className="space-y-2 md:col-span-2"><label htmlFor="footer-logo-url" className="text-xs font-bold uppercase tracking-widest text-slate-400">Logo URL</label><input id="footer-logo-url" type="url" value={logoUrl} onChange={event => setLogoUrl(event.target.value)} className="admin-input w-full" placeholder="https://example.com/logo.png" />{logoUrl && <div className="mt-3 inline-flex min-h-16 items-center rounded-lg border border-white/10 bg-black/20 p-3"><img src={logoUrl} alt="Footer logo preview" className="max-h-12 max-w-56 object-contain" /></div>}</div>
|
||||
<div className="space-y-2 md:col-span-2"><label className="text-xs font-bold uppercase tracking-widest text-slate-400">Footer description</label><div className="flex gap-2">{(['th', 'en'] as const).map(language => <button key={language} type="button" onClick={() => setActiveLanguage(language)} className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase ${activeLanguage === language ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'}`}>{language}</button>)}</div><textarea id="footer-description" value={description[activeLanguage] || ''} onChange={event => setDescription(current => ({ ...current, [activeLanguage]: event.target.value }))} className="admin-input min-h-24 w-full resize-y" placeholder={activeLanguage === 'th' ? 'กลิ่นหอมที่ออกแบบมาเพื่อบ้านของคุณ' : 'Scents designed for your home.'} /><p className="text-[10px] text-slate-500">Descriptions are saved per language and rendered using the visitor's locale.</p></div>
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<label className="text-xs font-bold uppercase tracking-widest text-slate-400">Footer description</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{FOOTER_LANGUAGES.map(language => (
|
||||
<button
|
||||
key={language}
|
||||
type="button"
|
||||
onClick={() => setActiveLanguage(language)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-bold uppercase cursor-pointer ${
|
||||
activeLanguage === language ? 'bg-cyan-glow text-night-900 shadow-sm' : 'bg-white/5 text-slate-400 hover:bg-white/10 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{language}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
id="footer-description"
|
||||
value={description[activeLanguage] || ''}
|
||||
onChange={event => setDescription(current => ({ ...current, [activeLanguage]: event.target.value }))}
|
||||
className="admin-input min-h-24 w-full resize-y"
|
||||
placeholder={activeLanguage === 'th' ? 'กลิ่นหอมที่ออกแบบมาเพื่อบ้านของคุณ' : 'Scents designed for your home.'}
|
||||
/>
|
||||
<p className="text-[10px] text-slate-500">Descriptions are saved per language and rendered using the visitor's locale.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -673,8 +673,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
||||
{/* Translations */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Translations</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{['th', 'ja', 'zh', 'vi', 'ko'].map(lang => {
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{languages.map(lang => {
|
||||
const transVal = item.translations?.find((t: any) => t.langCode === lang)?.label || ''
|
||||
return (
|
||||
<div key={lang} className="flex items-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user