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