This commit is contained in:
Your Name committed 2026-09-29 07:40:08 +07:00
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>
);
}
+55 -8
View File
@@ -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 = {
+521 -73
View File
@@ -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">