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">
@@ -0,0 +1,43 @@
-- AlterTable
ALTER TABLE "Order" ADD COLUMN "cashierId" INTEGER,
ADD COLUMN "changeAmount" DOUBLE PRECISION,
ADD COLUMN "channel" TEXT NOT NULL DEFAULT 'ONLINE',
ADD COLUMN "paymentMethod" TEXT DEFAULT 'CASH',
ADD COLUMN "receivedAmount" DOUBLE PRECISION,
ALTER COLUMN "customerName" DROP NOT NULL,
ALTER COLUMN "customerPhone" DROP NOT NULL,
ALTER COLUMN "customerEmail" DROP NOT NULL,
ALTER COLUMN "shippingAddress" DROP NOT NULL,
ALTER COLUMN "shippingFee" SET DEFAULT 0;
-- AlterTable
ALTER TABLE "Page" ADD COLUMN "ogImageUrl" TEXT,
ADD COLUMN "seoDescription" TEXT,
ADD COLUMN "seoKeywords" TEXT,
ADD COLUMN "seoTitle" TEXT;
-- AlterTable
ALTER TABLE "PageTranslation" ADD COLUMN "seoDescription" TEXT,
ADD COLUMN "seoKeywords" TEXT,
ADD COLUMN "seoTitle" TEXT;
-- AlterTable
ALTER TABLE "StoreSettings" ADD COLUMN "defaultOgImage" TEXT,
ADD COLUMN "defaultSeoDescription" TEXT,
ADD COLUMN "defaultSeoTitle" TEXT;
-- AlterTable
ALTER TABLE "StoreSettingsTranslation" ADD COLUMN "defaultSeoDescription" TEXT,
ADD COLUMN "defaultSeoTitle" TEXT;
-- CreateIndex
CREATE INDEX "Order_channel_idx" ON "Order"("channel");
-- CreateIndex
CREATE INDEX "Order_createdAt_idx" ON "Order"("createdAt");
-- CreateIndex
CREATE INDEX "TiktokSettlement_orderSettledTime_idx" ON "TiktokSettlement"("orderSettledTime");
-- CreateIndex
CREATE INDEX "TiktokSettlement_orderId_idx" ON "TiktokSettlement"("orderId");
+27 -15
View File
@@ -285,6 +285,9 @@ model StoreSettings {
scrollRevealStyle String @default("fade-up")
scrollRevealDuration Int @default(700)
headerStyle String @default("single_line")
defaultSeoTitle String?
defaultSeoDescription String?
defaultOgImage String?
translations StoreSettingsTranslation[]
updatedAt DateTime @updatedAt
}
@@ -299,6 +302,8 @@ model StoreSettingsTranslation {
newsletterDescription String?
newsletterPlaceholder String?
newsletterButtonText String?
defaultSeoTitle String?
defaultSeoDescription String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -660,24 +665,31 @@ model Review {
}
model Page {
id Int @id @default(autoincrement())
slug String @unique // e.g., "home", "about-us"
title String
sections PageSection[]
translations PageTranslation[]
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
slug String @unique // e.g., "home", "about-us"
title String
seoTitle String?
seoDescription String?
seoKeywords String?
ogImageUrl String?
sections PageSection[]
translations PageTranslation[]
isPublished Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model PageTranslation {
id Int @id @default(autoincrement())
pageId Int
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
langCode String
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id Int @id @default(autoincrement())
pageId Int
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
langCode String
title String
seoTitle String?
seoDescription String?
seoKeywords String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([pageId, langCode])
}
+254 -41
View File
@@ -221,6 +221,100 @@ export function mergeSectionTranslations(payload: any, translations: any, paths:
return { payload: result, translatedPaths };
}
export async function savePageTranslations(pageId: number, payload: any) {
if (!payload || typeof payload !== "object") return [];
const results: any[] = [];
const LANGS = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
const page = await prisma.page.findUnique({ where: { id: pageId } });
if (!page) return results;
if (Array.isArray(payload)) {
for (const item of payload) {
const lang = item.langCode || item.lang;
if (!lang || !LANGS.includes(lang)) continue;
const title = item.title ?? page.title;
const seoTitle = item.seoTitle !== undefined ? item.seoTitle : undefined;
const seoDescription = item.seoDescription !== undefined ? item.seoDescription : undefined;
const seoKeywords = item.seoKeywords !== undefined ? item.seoKeywords : undefined;
const updateData: any = {};
if (item.title !== undefined) updateData.title = item.title;
if (seoTitle !== undefined) updateData.seoTitle = seoTitle;
if (seoDescription !== undefined) updateData.seoDescription = seoDescription;
if (seoKeywords !== undefined) updateData.seoKeywords = seoKeywords;
const updated = await prisma.pageTranslation.upsert({
where: { pageId_langCode: { pageId, langCode: lang } },
update: updateData,
create: {
pageId,
langCode: lang,
title,
seoTitle: seoTitle ?? null,
seoDescription: seoDescription ?? null,
seoKeywords: seoKeywords ?? null,
},
});
results.push(updated);
}
return results;
}
for (const lang of LANGS) {
const langObj = payload[lang];
const title = typeof langObj?.title === "string"
? langObj.title
: typeof payload.title === "string"
? (lang === "th" ? payload.title : undefined)
: payload.title?.[lang];
const seoTitle = typeof langObj?.seoTitle === "string" || langObj?.seoTitle === null
? langObj.seoTitle
: typeof payload.seoTitle === "string" || payload.seoTitle === null
? (lang === "th" ? payload.seoTitle : undefined)
: payload.seoTitle?.[lang];
const seoDescription = typeof langObj?.seoDescription === "string" || langObj?.seoDescription === null
? langObj.seoDescription
: typeof payload.seoDescription === "string" || payload.seoDescription === null
? (lang === "th" ? payload.seoDescription : undefined)
: payload.seoDescription?.[lang];
const seoKeywords = typeof langObj?.seoKeywords === "string" || langObj?.seoKeywords === null
? langObj.seoKeywords
: typeof payload.seoKeywords === "string" || payload.seoKeywords === null
? (lang === "th" ? payload.seoKeywords : undefined)
: payload.seoKeywords?.[lang];
if (title === undefined && seoTitle === undefined && seoDescription === undefined && seoKeywords === undefined) {
continue;
}
const updateData: any = {};
if (title !== undefined) updateData.title = title;
if (seoTitle !== undefined) updateData.seoTitle = seoTitle;
if (seoDescription !== undefined) updateData.seoDescription = seoDescription;
if (seoKeywords !== undefined) updateData.seoKeywords = seoKeywords;
const updated = await prisma.pageTranslation.upsert({
where: { pageId_langCode: { pageId, langCode: lang } },
update: updateData,
create: {
pageId,
langCode: lang,
title: title ?? page.title,
seoTitle: seoTitle ?? null,
seoDescription: seoDescription ?? null,
seoKeywords: seoKeywords ?? null,
},
});
results.push(updated);
}
return results;
}
export const pagesRoute = new Elysia({ prefix: "/pages" })
.get("/", async ({ query }) => {
const view = String((query as any)?.view || "summary").toLowerCase();
@@ -230,6 +324,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
sections: {
orderBy: { order: "asc" },
},
translations: true,
},
});
}
@@ -239,6 +334,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
id: true,
slug: true,
title: true,
seoTitle: true,
seoDescription: true,
seoKeywords: true,
ogImageUrl: true,
isPublished: true,
createdAt: true,
updatedAt: true,
@@ -262,6 +361,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
where: includeInactive ? undefined : { isActive: true },
orderBy: { order: "asc" },
},
translations: true,
},
});
if (!page) {
@@ -273,9 +373,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
page.sections.map(async (section: any) => await hydrateSectionPayload(section, lang))
);
const trans = page.translations?.find((t: any) => t.langCode === lang);
const title = trans?.title || page.title;
const seoTitle = trans?.seoTitle || page.seoTitle;
const seoDescription = trans?.seoDescription || page.seoDescription;
const seoKeywords = trans?.seoKeywords || page.seoKeywords;
return {
...page,
title,
seoTitle,
seoDescription,
seoKeywords,
ogImageUrl: page.ogImageUrl,
isPublished: page.isPublished,
sections: populatedSections,
translations: page.translations,
};
})
.get("/:slug/sections", async ({ params: { slug }, set }) => {
@@ -545,6 +658,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
title: string;
slug: string;
isPublished?: boolean;
seoTitle?: string | null;
seoDescription?: string | null;
seoKeywords?: string | null;
ogImageUrl?: string | null;
};
section: {
type: string;
@@ -617,6 +734,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
title: pageInput.title,
slug: pageInput.slug,
isPublished: pageInput.isPublished ?? false,
seoTitle: pageInput.seoTitle ?? null,
seoDescription: pageInput.seoDescription ?? null,
seoKeywords: pageInput.seoKeywords ?? null,
ogImageUrl: pageInput.ogImageUrl ?? null,
},
});
const section = await tx.pageSection.create({
@@ -661,6 +782,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
title: t.String(),
slug: t.String(),
isPublished: t.Optional(t.Boolean()),
seoTitle: t.Optional(t.Nullable(t.String())),
seoDescription: t.Optional(t.Nullable(t.String())),
seoKeywords: t.Optional(t.Nullable(t.String())),
ogImageUrl: t.Optional(t.Nullable(t.String())),
}),
section: t.Object({
type: t.String(),
@@ -677,7 +802,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const data = body as any;
const operation = data?.id ? "update" : "create";
try {
const { id, slug, title, isPublished, status, metadata } = data;
const { id, slug, title, isPublished, status, metadata, seoTitle, seoDescription, seoKeywords, ogImageUrl, translations } = data;
if (
typeof slug === "string" &&
@@ -752,14 +877,41 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
}
}
const page = id
? await prisma.page.update({
where: { id: Number(id) },
data: { slug, title, isPublished: finalIsPublished },
})
: await prisma.page.create({
data: { slug, title, isPublished: finalIsPublished },
});
let page: any;
if (id) {
const updateData: any = { slug, title, isPublished: finalIsPublished };
if (seoTitle !== undefined) updateData.seoTitle = seoTitle;
if (seoDescription !== undefined) updateData.seoDescription = seoDescription;
if (seoKeywords !== undefined) updateData.seoKeywords = seoKeywords;
if (ogImageUrl !== undefined) updateData.ogImageUrl = ogImageUrl;
page = await prisma.page.update({
where: { id: Number(id) },
data: updateData,
include: { translations: true },
});
} else {
page = await prisma.page.create({
data: {
slug,
title,
isPublished: finalIsPublished,
seoTitle: seoTitle ?? null,
seoDescription: seoDescription ?? null,
seoKeywords: seoKeywords ?? null,
ogImageUrl: ogImageUrl ?? null,
},
include: { translations: true },
});
}
if (translations) {
await savePageTranslations(page.id, translations);
page = await prisma.page.findUnique({
where: { id: page.id },
include: { translations: true },
});
}
return {
success: true,
@@ -802,6 +954,80 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
slug: t.String(),
title: t.String(),
isPublished: t.Optional(t.Boolean()),
seoTitle: t.Optional(t.Nullable(t.String())),
seoDescription: t.Optional(t.Nullable(t.String())),
seoKeywords: t.Optional(t.Nullable(t.String())),
ogImageUrl: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Any()),
}),
},
)
.put(
"/:id",
async ({ params: { id: pageParam }, body, set }) => {
try {
const numId = Number(pageParam);
const isNumericId = Number.isInteger(numId) && numId > 0;
const existing = await prisma.page.findFirst({
where: isNumericId ? { id: numId } : { slug: pageParam },
});
if (!existing) {
set.status = 404;
return { error: "Page not found" };
}
const data = body as any;
const { slug, title, isPublished, seoTitle, seoDescription, seoKeywords, ogImageUrl, translations } = data;
if (
typeof slug === "string" &&
slug !== existing.slug &&
RESERVED_SLUGS.includes(slug.toLowerCase())
) {
set.status = 400;
return { success: false, error: "Reserved slug cannot be used" };
}
const updateData: any = {};
if (slug !== undefined) updateData.slug = slug;
if (title !== undefined) updateData.title = title;
if (isPublished !== undefined) updateData.isPublished = isPublished;
if (seoTitle !== undefined) updateData.seoTitle = seoTitle;
if (seoDescription !== undefined) updateData.seoDescription = seoDescription;
if (seoKeywords !== undefined) updateData.seoKeywords = seoKeywords;
if (ogImageUrl !== undefined) updateData.ogImageUrl = ogImageUrl;
let page = await prisma.page.update({
where: { id: existing.id },
data: updateData,
include: { translations: true },
});
if (translations) {
await savePageTranslations(page.id, translations);
page = await prisma.page.findUnique({
where: { id: page.id },
include: { translations: true, sections: { orderBy: { order: "asc" } } },
}) as any;
}
return { success: true, page };
} catch (err) {
console.error("[Pages] Update Error:", err);
set.status = 500;
return { error: "Failed to update page" };
}
},
{
body: t.Object({
slug: t.Optional(t.String()),
title: t.Optional(t.String()),
isPublished: t.Optional(t.Boolean()),
seoTitle: t.Optional(t.Nullable(t.String())),
seoDescription: t.Optional(t.Nullable(t.String())),
seoKeywords: t.Optional(t.Nullable(t.String())),
ogImageUrl: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Any()),
}),
},
)
@@ -818,41 +1044,12 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.patch("/:id/translations", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id)) {
if (isNaN(id) || id <= 0) {
set.status = 400;
return { error: "Invalid page ID" };
}
const payload = body as any;
const results: any[] = [];
const LANGS = ["th", "en", "ja", "zh", "vi", "ko", "lo", "it"];
for (const lang of LANGS) {
const title =
typeof payload.title === "string"
? lang === "th"
? payload.title
: null
: payload.title?.[lang];
if (!title) continue;
const existing = await prisma.pageTranslation.findUnique({
where: { pageId_langCode: { pageId: id, langCode: lang } },
});
if (existing) {
const updated = await prisma.pageTranslation.update({
where: { id: existing.id },
data: { title },
});
results.push(updated);
} else {
const created = await prisma.pageTranslation.create({
data: { pageId: id, langCode: lang, title },
});
results.push(created);
}
}
const results = await savePageTranslations(id, body);
const updated = await prisma.page.findUnique({
where: { id },
@@ -860,7 +1057,23 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
});
return { success: true, translations: results, page: updated };
})
}, { body: t.Any() })
.post("/:id/translations", async ({ params, body, set }) => {
const id = Number(params.id);
if (isNaN(id) || id <= 0) {
set.status = 400;
return { error: "Invalid page ID" };
}
const results = await savePageTranslations(id, body);
const updated = await prisma.page.findUnique({
where: { id },
include: { translations: true },
});
return { success: true, translations: results, page: updated };
}, { body: t.Any() })
.post(
"/:slug/sections",
async ({ params: { slug }, body, set }) => {
+287 -61
View File
@@ -2,6 +2,141 @@ import { Elysia, t } from "elysia";
import prisma from "../lib/prisma";
import { adminOnly } from "../middleware/auth";
export async function saveStoreSettingsTranslations(settingsId: number, payload: any) {
const results: any[] = [];
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
if (Array.isArray(payload)) {
for (const item of payload) {
const lang = item.langCode || item.lang;
if (!lang || !LANGS.includes(lang)) continue;
const updateData: any = {};
if (item.footerDescription !== undefined) updateData.footerDescription = item.footerDescription;
if (item.newsletterTitle !== undefined) updateData.newsletterTitle = item.newsletterTitle;
if (item.newsletterDescription !== undefined) updateData.newsletterDescription = item.newsletterDescription;
if (item.newsletterPlaceholder !== undefined) updateData.newsletterPlaceholder = item.newsletterPlaceholder;
if (item.newsletterButtonText !== undefined) updateData.newsletterButtonText = item.newsletterButtonText;
if (item.defaultSeoTitle !== undefined) updateData.defaultSeoTitle = item.defaultSeoTitle;
if (item.defaultSeoDescription !== undefined) updateData.defaultSeoDescription = item.defaultSeoDescription;
const existing = await prisma.storeSettingsTranslation.findUnique({
where: { storeSettingsId_langCode: { storeSettingsId: settingsId, langCode: lang } }
});
if (existing) {
const updated = await prisma.storeSettingsTranslation.update({
where: { id: existing.id },
data: updateData
});
results.push(updated);
} else {
const created = await prisma.storeSettingsTranslation.create({
data: { storeSettingsId: settingsId, langCode: lang, ...updateData }
});
results.push(created);
}
}
return results;
}
for (const lang of LANGS) {
const langObj = payload[lang];
const footerDescription = typeof langObj?.footerDescription === 'string' || langObj?.footerDescription === null
? langObj.footerDescription
: typeof payload.footerDescription === 'string' || payload.footerDescription === null
? (lang === 'th' ? payload.footerDescription : undefined)
: payload.footerDescription?.[lang];
const newsletterTitle = typeof langObj?.newsletterTitle === 'string' || langObj?.newsletterTitle === null
? langObj.newsletterTitle
: typeof payload.newsletterTitle === 'string' || payload.newsletterTitle === null
? (lang === 'th' ? payload.newsletterTitle : undefined)
: payload.newsletterTitle?.[lang];
const newsletterDescription = typeof langObj?.newsletterDescription === 'string' || langObj?.newsletterDescription === null
? langObj.newsletterDescription
: typeof payload.newsletterDescription === 'string' || payload.newsletterDescription === null
? (lang === 'th' ? payload.newsletterDescription : undefined)
: payload.newsletterDescription?.[lang];
const newsletterPlaceholder = typeof langObj?.newsletterPlaceholder === 'string' || langObj?.newsletterPlaceholder === null
? langObj.newsletterPlaceholder
: typeof payload.newsletterPlaceholder === 'string' || payload.newsletterPlaceholder === null
? (lang === 'th' ? payload.newsletterPlaceholder : undefined)
: payload.newsletterPlaceholder?.[lang];
const newsletterButtonText = typeof langObj?.newsletterButtonText === 'string' || langObj?.newsletterButtonText === null
? langObj.newsletterButtonText
: typeof payload.newsletterButtonText === 'string' || payload.newsletterButtonText === null
? (lang === 'th' ? payload.newsletterButtonText : undefined)
: payload.newsletterButtonText?.[lang];
const defaultSeoTitle = typeof langObj?.defaultSeoTitle === 'string' || langObj?.defaultSeoTitle === null
? langObj.defaultSeoTitle
: typeof payload.defaultSeoTitle === 'string' || payload.defaultSeoTitle === null
? (lang === 'th' ? payload.defaultSeoTitle : undefined)
: payload.defaultSeoTitle?.[lang];
const defaultSeoDescription = typeof langObj?.defaultSeoDescription === 'string' || langObj?.defaultSeoDescription === null
? langObj.defaultSeoDescription
: typeof payload.defaultSeoDescription === 'string' || payload.defaultSeoDescription === null
? (lang === 'th' ? payload.defaultSeoDescription : undefined)
: payload.defaultSeoDescription?.[lang];
if (
footerDescription === undefined &&
newsletterTitle === undefined &&
newsletterDescription === undefined &&
newsletterPlaceholder === undefined &&
newsletterButtonText === undefined &&
defaultSeoTitle === undefined &&
defaultSeoDescription === undefined
) {
continue;
}
const updateData: any = {};
if (footerDescription !== undefined) updateData.footerDescription = footerDescription;
if (newsletterTitle !== undefined) updateData.newsletterTitle = newsletterTitle;
if (newsletterDescription !== undefined) updateData.newsletterDescription = newsletterDescription;
if (newsletterPlaceholder !== undefined) updateData.newsletterPlaceholder = newsletterPlaceholder;
if (newsletterButtonText !== undefined) updateData.newsletterButtonText = newsletterButtonText;
if (defaultSeoTitle !== undefined) updateData.defaultSeoTitle = defaultSeoTitle;
if (defaultSeoDescription !== undefined) updateData.defaultSeoDescription = defaultSeoDescription;
const existing = await prisma.storeSettingsTranslation.findUnique({
where: { storeSettingsId_langCode: { storeSettingsId: settingsId, langCode: lang } }
});
if (existing) {
const updated = await prisma.storeSettingsTranslation.update({
where: { id: existing.id },
data: updateData
});
results.push(updated);
} else {
const created = await prisma.storeSettingsTranslation.create({
data: { storeSettingsId: settingsId, langCode: lang, ...updateData }
});
results.push(created);
}
if (lang === 'th') {
const baseUpdates: any = {};
if (footerDescription !== undefined) baseUpdates.footerDescription = footerDescription;
if (defaultSeoTitle !== undefined) baseUpdates.defaultSeoTitle = defaultSeoTitle;
if (defaultSeoDescription !== undefined) baseUpdates.defaultSeoDescription = defaultSeoDescription;
if (Object.keys(baseUpdates).length > 0) {
await prisma.storeSettings.update({
where: { id: settingsId },
data: baseUpdates
});
}
}
}
return results;
}
export const settingsRoute = new Elysia({ prefix: "/settings" })
.get("/", async ({ set, query }) => {
try {
@@ -13,19 +148,27 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
});
}
const { translations, ...settingsData } = settings;
const requestedLang = typeof query?.lang === "string" ? query.lang : undefined;
const localizedDescription = requestedLang
? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription
const requestedLang = typeof (query as any)?.lang === "string" ? (query as any).lang : undefined;
const translation = requestedLang
? translations.find((t) => t.langCode === requestedLang)
: undefined;
const localizedDescription = translation?.footerDescription;
const localizedSeoTitle = translation?.defaultSeoTitle;
const localizedSeoDescription = translation?.defaultSeoDescription;
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settingsData,
translations,
headerBehavior,
mobileHeaderBehavior,
mobileHeaderStyle,
footerDescription: localizedDescription ?? settingsData.footerDescription,
defaultSeoTitle: localizedSeoTitle ?? settingsData.defaultSeoTitle,
defaultSeoDescription: localizedSeoDescription ?? settingsData.defaultSeoDescription,
defaultOgImage: settingsData.defaultOgImage,
};
} catch (err) {
console.error("[Settings] Error:", err);
@@ -67,7 +210,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
}
})
.use(adminOnly)
.patch("/footer-brand", async ({ body, set }) => {
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
try {
const payload = body as any;
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
@@ -118,9 +261,9 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
return { error: err?.message || "Failed to update footer brand" };
}
}, { body: t.Any() })
.put("/", async ({ body, set }) => {
.put("/", async ({ body, set }: { body: any; set: any }) => {
try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, ...data } = body as any;
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens
: {};
@@ -136,18 +279,27 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
}
data.themeTokens = updatedTokens;
let settings = await prisma.storeSettings.findFirst();
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
if (!settings) {
settings = await prisma.storeSettings.create({ data });
settings = await prisma.storeSettings.create({ data, include: { translations: true } });
} else {
settings = await prisma.storeSettings.update({
where: { id: settings.id },
data
data,
include: { translations: true },
});
}
const returnedBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const returnedMobileBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
if (translations) {
await saveStoreSettingsTranslations(settings.id, translations);
settings = (await prisma.storeSettings.findFirst({
include: { translations: true },
})) as any;
}
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settings,
headerBehavior: returnedBehavior,
@@ -167,17 +319,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
bankAccountName: t.Optional(t.String()),
bankAccountNumber: t.Optional(t.String()),
pointsEarnRate: t.Optional(t.Number()),
logoUrl: t.Optional(t.String()),
logoUrl: t.Optional(t.Nullable(t.String())),
themePreset: t.Optional(t.String()),
themeTokens: t.Optional(t.Any()),
facebookUrl: t.Optional(t.String()),
instagramUrl: t.Optional(t.String()),
tiktokUrl: t.Optional(t.String()),
footerDescription: t.Optional(t.String()),
newsletterTitle: t.Optional(t.String()),
newsletterDescription: t.Optional(t.String()),
newsletterPlaceholder: t.Optional(t.String()),
newsletterButtonText: t.Optional(t.String()),
facebookUrl: t.Optional(t.Nullable(t.String())),
instagramUrl: t.Optional(t.Nullable(t.String())),
tiktokUrl: t.Optional(t.Nullable(t.String())),
footerDescription: t.Optional(t.Nullable(t.String())),
newsletterTitle: t.Optional(t.Nullable(t.String())),
newsletterDescription: t.Optional(t.Nullable(t.String())),
newsletterPlaceholder: t.Optional(t.Nullable(t.String())),
newsletterButtonText: t.Optional(t.Nullable(t.String())),
footerSupportHeading: t.Optional(t.Any()),
footerCopyrightText: t.Optional(t.Any()),
scrollRevealEnabled: t.Optional(t.Boolean()),
@@ -185,52 +337,106 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
scrollRevealDuration: t.Optional(t.Number()),
headerStyle: t.Optional(t.String()),
headerBehavior: t.Optional(t.String()),
mobileHeaderBehavior: t.Optional(t.String()),
mobileHeaderStyle: t.Optional(t.String()),
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Any()),
})
})
.patch("/translations", async ({ body, set }) => {
.post("/", async ({ body, set }: { body: any; set: any }) => {
try {
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
? data.themeTokens
: {};
const updatedTokens = { ...currentTokens };
if (headerBehavior !== undefined) {
updatedTokens.headerBehavior = headerBehavior;
}
if (mobileHeaderBehavior !== undefined) {
updatedTokens.mobileHeaderBehavior = mobileHeaderBehavior;
}
if (mobileHeaderStyle !== undefined) {
updatedTokens.mobileHeaderStyle = mobileHeaderStyle;
}
data.themeTokens = updatedTokens;
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
if (!settings) {
settings = await prisma.storeSettings.create({ data, include: { translations: true } });
} else {
settings = await prisma.storeSettings.update({
where: { id: settings.id },
data,
include: { translations: true },
});
}
if (translations) {
await saveStoreSettingsTranslations(settings.id, translations);
settings = (await prisma.storeSettings.findFirst({
include: { translations: true },
})) as any;
}
const returnedBehavior = (settings?.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
const returnedMobileBehavior = (settings?.themeTokens as any)?.mobileHeaderBehavior ?? returnedBehavior;
const returnedMobileStyle = (settings?.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
return {
...settings,
headerBehavior: returnedBehavior,
mobileHeaderBehavior: returnedMobileBehavior,
mobileHeaderStyle: returnedMobileStyle,
};
} catch (err) {
console.error("[Settings] Error:", err);
set.status = 500;
return { error: "Failed to update settings" };
}
}, {
body: t.Object({
brandName: t.Optional(t.String()),
shippingFee: t.Optional(t.Number()),
bankName: t.Optional(t.String()),
bankAccountName: t.Optional(t.String()),
bankAccountNumber: t.Optional(t.String()),
pointsEarnRate: t.Optional(t.Number()),
logoUrl: t.Optional(t.Nullable(t.String())),
themePreset: t.Optional(t.String()),
themeTokens: t.Optional(t.Any()),
facebookUrl: t.Optional(t.Nullable(t.String())),
instagramUrl: t.Optional(t.Nullable(t.String())),
tiktokUrl: t.Optional(t.Nullable(t.String())),
footerDescription: t.Optional(t.Nullable(t.String())),
newsletterTitle: t.Optional(t.Nullable(t.String())),
newsletterDescription: t.Optional(t.Nullable(t.String())),
newsletterPlaceholder: t.Optional(t.Nullable(t.String())),
newsletterButtonText: t.Optional(t.Nullable(t.String())),
footerSupportHeading: t.Optional(t.Any()),
footerCopyrightText: t.Optional(t.Any()),
scrollRevealEnabled: t.Optional(t.Boolean()),
scrollRevealStyle: t.Optional(t.String()),
scrollRevealDuration: t.Optional(t.Number()),
headerStyle: t.Optional(t.String()),
headerBehavior: t.Optional(t.String()),
mobileHeaderBehavior: t.Optional(t.String()),
mobileHeaderStyle: t.Optional(t.String()),
defaultSeoTitle: t.Optional(t.Nullable(t.String())),
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
defaultOgImage: t.Optional(t.Nullable(t.String())),
translations: t.Optional(t.Any()),
})
})
.patch("/translations", async ({ body, set }: { body: any; set: any }) => {
try {
let settings = await prisma.storeSettings.findFirst();
if (!settings) {
settings = await prisma.storeSettings.create({ data: {} });
}
const payload = body as any;
const results: any[] = [];
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko', 'lo', 'it'];
for (const lang of LANGS) {
const footerDescription = typeof payload.footerDescription === 'string' ? (lang === 'th' ? payload.footerDescription : null) : payload.footerDescription?.[lang];
const newsletterTitle = typeof payload.newsletterTitle === 'string' ? (lang === 'th' ? payload.newsletterTitle : null) : payload.newsletterTitle?.[lang];
const newsletterDescription = typeof payload.newsletterDescription === 'string' ? (lang === 'th' ? payload.newsletterDescription : null) : payload.newsletterDescription?.[lang];
const newsletterPlaceholder = typeof payload.newsletterPlaceholder === 'string' ? (lang === 'th' ? payload.newsletterPlaceholder : null) : payload.newsletterPlaceholder?.[lang];
const newsletterButtonText = typeof payload.newsletterButtonText === 'string' ? (lang === 'th' ? payload.newsletterButtonText : null) : payload.newsletterButtonText?.[lang];
if (!footerDescription && !newsletterTitle && !newsletterDescription && !newsletterPlaceholder && !newsletterButtonText) continue;
const existing = await prisma.storeSettingsTranslation.findUnique({
where: { storeSettingsId_langCode: { storeSettingsId: settings.id, langCode: lang } }
});
const updateData: any = {};
if (footerDescription) updateData.footerDescription = footerDescription;
if (newsletterTitle) updateData.newsletterTitle = newsletterTitle;
if (newsletterDescription) updateData.newsletterDescription = newsletterDescription;
if (newsletterPlaceholder) updateData.newsletterPlaceholder = newsletterPlaceholder;
if (newsletterButtonText) updateData.newsletterButtonText = newsletterButtonText;
if (existing) {
const updated = await prisma.storeSettingsTranslation.update({
where: { id: existing.id },
data: updateData
});
results.push(updated);
} else {
const created = await prisma.storeSettingsTranslation.create({
data: { storeSettingsId: settings.id, langCode: lang, ...updateData }
});
results.push(created);
}
}
const results = await saveStoreSettingsTranslations(settings.id, body);
const updatedSettings = await prisma.storeSettings.findFirst({
include: { translations: true }
@@ -242,8 +448,28 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 500;
return { error: "Failed to update settings translation" };
}
})
.post("/seed-pages", async ({ set }) => {
}, { body: t.Any() })
.post("/translations", async ({ body, set }: { body: any; set: any }) => {
try {
let settings = await prisma.storeSettings.findFirst();
if (!settings) {
settings = await prisma.storeSettings.create({ data: {} });
}
const results = await saveStoreSettingsTranslations(settings.id, body);
const updatedSettings = await prisma.storeSettings.findFirst({
include: { translations: true }
});
return { success: true, translations: results, settings: updatedSettings };
} catch (err) {
console.error("[Settings] Translation Error:", err);
set.status = 500;
return { error: "Failed to update settings translation" };
}
}, { body: t.Any() })
.post("/seed-pages", async ({ set }: { set: any }) => {
type SectionInput = { type: string; order: number; isActive: boolean; payload: object };
type PageDef = { slug: string; title: string; sections: SectionInput[] };
+12 -8
View File
@@ -393,7 +393,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
},
{
name: "update_store_settings_translation",
description: "Update translated footer and newsletter text for a specific language.",
description: "Update translated footer, newsletter, and default SEO text for a specific language.",
inputSchema: {
type: "object",
properties: {
@@ -402,7 +402,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
newsletterTitle: { type: "string" },
newsletterDescription: { type: "string" },
newsletterPlaceholder: { type: "string" },
newsletterButtonText: { type: "string" }
newsletterButtonText: { type: "string" },
defaultSeoTitle: { type: "string" },
defaultSeoDescription: { type: "string" }
},
required: ["lang"]
}
@@ -1388,13 +1390,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_store_settings_translation": {
const { lang, footerDescription, newsletterTitle, newsletterDescription, newsletterPlaceholder, newsletterButtonText } = params as any;
const { lang, footerDescription, newsletterTitle, newsletterDescription, newsletterPlaceholder, newsletterButtonText, defaultSeoTitle, defaultSeoDescription } = params as any;
const payload: any = {};
if (footerDescription) payload.footerDescription = { [lang]: footerDescription };
if (newsletterTitle) payload.newsletterTitle = { [lang]: newsletterTitle };
if (newsletterDescription) payload.newsletterDescription = { [lang]: newsletterDescription };
if (newsletterPlaceholder) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
if (newsletterButtonText) payload.newsletterButtonText = { [lang]: newsletterButtonText };
if (footerDescription !== undefined) payload.footerDescription = { [lang]: footerDescription };
if (newsletterTitle !== undefined) payload.newsletterTitle = { [lang]: newsletterTitle };
if (newsletterDescription !== undefined) payload.newsletterDescription = { [lang]: newsletterDescription };
if (newsletterPlaceholder !== undefined) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
if (newsletterButtonText !== undefined) payload.newsletterButtonText = { [lang]: newsletterButtonText };
if (defaultSeoTitle !== undefined) payload.defaultSeoTitle = { [lang]: defaultSeoTitle };
if (defaultSeoDescription !== undefined) payload.defaultSeoDescription = { [lang]: defaultSeoDescription };
const res = await api.patch(`/settings/translations`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
+14 -2
View File
@@ -39,11 +39,23 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
const pageResult = await getCmsPageResult(slug, lang)
if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found' }
const page = pageResult.data
const title = page.seoTitle || page.title
const description = page.seoDescription || page.title
const ogImage = page.ogImageUrl
return generateSeoMetadata({
title: `${pageResult.data.title}`,
description: `${pageResult.data.title}`,
title,
description,
path: `/${slug}`,
lang,
openGraph: ogImage
? {
title,
description,
images: [ogImage],
}
: undefined,
})
}
@@ -1,5 +1,24 @@
import type { Metadata } from 'next'
import Link from '@/components/LocalizedLink'
import { API_BASE } from '@/lib/api'
import { generateSeoMetadata } from '@/lib/seo'
import { getStoreSettings } from '@/lib/cms'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
const settings = await getStoreSettings(lang)
const brandName = settings?.brandName || ''
return generateSeoMetadata({
title: brandName ? `About ${brandName}` : 'About Us',
description: brandName
? `Learn about ${brandName} — our story, mission, and commitment to pure botanical craftsmanship.`
: 'Learn about our story, mission, and commitment to pure botanical craftsmanship.',
path: '/about',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
})
}
const PILLARS = [
{
@@ -7,7 +7,7 @@ import type { Category, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
@@ -25,14 +25,32 @@ async function getCategoryProducts(id: string, lang: string): Promise<ApiResult<
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params
const result = await getCategoryProducts(id, lang)
const [result, settings] = await Promise.all([
getCategoryProducts(id, lang),
getStoreSettings(lang),
])
const categoryName = result.ok && result.data.category ? resolveLocalized(result.data.category.name, lang) : ''
const brandName = settings?.brandName || ''
const description = categoryName
? (brandName ? `Shop ${categoryName} products from ${brandName}.` : `Shop ${categoryName} products.`)
: (brandName ? `Shop ${brandName} category products.` : 'Shop category products.')
const category = result.ok ? result.data.category : undefined
const categoryImage = category?.imageUrl || (categoryName ? getCategoryImage(categoryName) : undefined) || settings?.defaultOgImage
return generateSeoMetadata({
title: categoryName ? `${categoryName}` : 'Category',
description: categoryName ? `Shop ${categoryName} products from .` : 'Shop category products.',
description,
path: `/category/${id}`,
lang,
openGraph: categoryImage
? {
title: categoryName || undefined,
description,
images: [categoryImage],
}
: undefined,
})
}
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
import CheckoutClient from '@/components/CheckoutClient'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ${brandName}`
: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ';
return generateSeoMetadata({
title: 'Checkout',
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ',
description,
path: '/checkout',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+17 -3
View File
@@ -1,8 +1,22 @@
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import { getStoreSettings } from '@/lib/cms'
export const metadata: Metadata = {
title: 'Contact Us',
description: 'Get in touch with . We respond to all inquiries within 2 hours.',
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
const settings = await getStoreSettings(lang)
const brandName = settings?.brandName || ''
const description = brandName
? `Get in touch with ${brandName}. We respond to all inquiries within 2 hours.`
: 'Get in touch with us. We respond to all inquiries within 2 hours.'
return generateSeoMetadata({
title: 'Contact Us',
description,
path: '/contact',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
})
}
export default function ContactLayout({ children }: { children: React.ReactNode }) {
+9 -2
View File
@@ -2,7 +2,7 @@ import Link from '@/components/LocalizedLink'
import { Ticket, ArrowRight } from 'lucide-react'
import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { toneClass } from '@/lib/theme'
@@ -16,11 +16,18 @@ function formatDiscount(type: string, value: number) {
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก ${brandName} สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ`
: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษสำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ';
return generateSeoMetadata({
title: 'โปรโมชั่นและคูปองส่วนลด',
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
description,
path: '/coupons',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+17 -3
View File
@@ -1,8 +1,22 @@
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import { getStoreSettings } from '@/lib/cms'
export const metadata: Metadata = {
title: 'FAQ',
description: 'Everything you need to know about products, shipping, and care.',
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
const { lang } = await params
const settings = await getStoreSettings(lang)
const brandName = settings?.brandName || ''
const description = brandName
? `Everything you need to know about ${brandName} products, shipping, and care.`
: 'Everything you need to know about our products, shipping, and care.'
return generateSeoMetadata({
title: 'FAQ',
description,
path: '/faq',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
})
}
export default function FaqLayout({ children }: { children: React.ReactNode }) {
+10 -1
View File
@@ -13,14 +13,23 @@ export async function generateMetadata({ params }: { params: Promise<{ lang: str
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = settings?.defaultSeoDescription || settings?.footerDescription || 'Pure Origins, Artisan Craft, Zero Compromise';
const ogImage = settings?.defaultOgImage;
return {
title: {
template: `%s | ${brandName}`,
default: brandName,
},
description: 'Pure Origins, Artisan Craft, Zero Compromise',
description,
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
openGraph: ogImage
? {
title: brandName,
description,
images: [ogImage],
}
: undefined,
};
}
@@ -7,7 +7,7 @@ import { CheckCircle2, Clock, PackageCheck, Truck } from 'lucide-react'
import { formatPrice } from '@homaroma/types'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getOrderResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { orderStatusBadgeClass, toneClass } from '@/lib/theme'
@@ -31,15 +31,24 @@ function statusLabel(status: string) {
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
const { id, lang } = await params
const result = await getOrderResult(id)
const [result, settings] = await Promise.all([
getOrderResult(id),
getStoreSettings(lang),
])
if (!result.ok) return { title: 'Order Not Found' }
const brandName = settings?.brandName || ''
const description = brandName
? `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก ${brandName}`
: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)}`
return generateSeoMetadata({
title: `Order ${orderLabel(result.data.id)}`,
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก `,
description,
path: `/order/${id}`,
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
})
}
+21 -4
View File
@@ -1,7 +1,7 @@
import type { Metadata } from 'next'
import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import { getCategories, getCmsPage } from '@/lib/cms'
import { getCategories, getCmsPage, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
type Props = {
@@ -10,13 +10,30 @@ type Props = {
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { lang } = await params
const page = await getCmsPage('home', lang)
const [page, settings] = await Promise.all([
getCmsPage('home', lang),
getStoreSettings(lang),
])
const title = page?.seoTitle || page?.title || undefined
const description =
page?.seoDescription ||
settings?.defaultSeoDescription ||
(settings?.brandName ? `${settings.brandName} - Pure Origins, Artisan Craft` : 'Pure Origins, Artisan Craft')
const ogImage = page?.ogImageUrl || settings?.defaultOgImage
return generateSeoMetadata({
title: page?.title ? `${page.title}` : undefined,
description: 'Premium aromatherapy, home fragrance, and botanical scent products from .',
title,
description,
path: '',
lang,
openGraph: ogImage
? {
title: page?.seoTitle || page?.title || settings?.brandName || undefined,
description,
images: [ogImage],
}
: undefined,
})
}
@@ -11,7 +11,7 @@ import ProductGallery from '@/components/ProductGallery'
import ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, getStoreSettingsResult, type ProductReview } from '@/lib/cms'
import { getCategoriesResult, getProduct, getProductDetailSectionsResult, getProductResult, getProductReviewsResult, getProductsResult, getStoreSettings, getStoreSettingsResult, type ProductReview } from '@/lib/cms'
import { resolveCategoryName, resolveLocalized } from '@/lib/localization'
import { SUPPORTED_LOCALES } from '@/lib/locales'
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
@@ -352,24 +352,32 @@ function ProductReviewsSection({
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
const { slug, lang } = await params
const id = extractIdFromSlug(slug)
const product = await getProduct(id, lang)
const [product, settings] = await Promise.all([
getProduct(id, lang),
getStoreSettings(lang),
])
if (!product) return { title: 'Product Not Found' }
const productName = resolveLocalized(product.name, lang) || product.brand.name
const brandName = product.brand?.name || settings?.brandName || ''
const productName = resolveLocalized(product.name, lang) || brandName
const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang)
const rawDescription = resolveLocalized(product.description, lang)
const description =
rawDescription || (categoryName ? (brandName ? `${categoryName} from ${brandName}` : categoryName) : productName)
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
const customLanguages: Record<string, string> = {}
for (const l of SUPPORTED_LOCALES) {
const locName = resolveLocalized(product.name, l) || product.brand.name
const locName = resolveLocalized(product.name, l) || brandName
customLanguages[l] = `${baseUrl}/${l}/products/${generateProductSlug(locName, product.id)}`
}
const ogImage = product.images?.[0]?.url || settings?.defaultOgImage
return generateSeoMetadata({
title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
description: description || `Premium ${categoryName} from .`,
description,
path: `/products/${slug}`,
lang,
customLanguages,
@@ -377,7 +385,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
type: 'website',
title: productName,
description,
images: product.images?.[0]?.url ? [product.images[0].url] : [],
images: ogImage ? [ogImage] : [],
},
})
}
+9 -2
View File
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProfileClient from '@/components/ProfileClient'
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `จัดการโปรไฟล์สมาชิก ${brandName} ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง`
: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง';
return generateSeoMetadata({
title: 'โปรไฟล์สมาชิก',
description: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
description,
path: '/profile',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+9 -2
View File
@@ -2,17 +2,24 @@ import Link from '@/components/LocalizedLink'
import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getProductsResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `Search ${brandName} products by scent, category, or keyword.`
: 'Search products by scent, category, or keyword.';
return generateSeoMetadata({
title: 'Search Products',
description: 'Search products by scent, category, or keyword.',
description,
path: '/search',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+9 -2
View File
@@ -3,18 +3,25 @@ import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import ProductCard from '@/components/ProductCard'
import ShopCouponBridge from '@/components/ShopCouponBridge'
import { getCategoriesResult, getCmsPageResult, getProductsResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getProductsResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
import { resolveLocalized } from '@/lib/localization'
// lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `Browse ${brandName} aroma oils, reed diffusers, room sprays, and premium scent products.`
: 'Browse aroma oils, reed diffusers, room sprays, and premium scent products.';
return generateSeoMetadata({
title: 'Shop Aroma Products',
description: 'Browse aroma oils, reed diffusers, room sprays, and premium scent products.',
description,
path: '/shop',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+9 -2
View File
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import TrackOrderClient from '@/components/TrackOrderClient'
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `ตรวจสอบสถานะคำสั่งซื้อ ${brandName} ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ`
: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ';
return generateSeoMetadata({
title: 'ติดตามคำสั่งซื้อ',
description: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
description,
path: '/track',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
import WishlistClient from '@/components/WishlistClient'
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
import { splitCmsLayoutSections } from '@/lib/cms-sections'
// lang is extracted from params
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
const { lang } = await params;
const settings = await getStoreSettings(lang);
const brandName = settings?.brandName || '';
const description = brandName
? `รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ${brandName}`
: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist';
return generateSeoMetadata({
title: 'Wishlist',
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ',
description,
path: '/wishlist',
lang,
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
});
}
+4
View File
@@ -82,6 +82,10 @@ export async function getCategoriesResult(lang = 'all'): Promise<ApiResult<Categ
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 0 } })
}
export async function getStoreSettings(lang = 'all'): Promise<StoreSettings | null> {
return await fetchJson<StoreSettings>(`/settings?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}
export async function getStoreSettingsResult(lang = 'all'): Promise<ApiResult<StoreSettings>> {
return await fetchJsonResult<StoreSettings>(`/settings?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
}