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: 'en', label: 'EN', nativeName: 'English', flag: '🇬🇧' },
|
||||||
{ code: 'ja', label: 'JA', nativeName: '日本語', flag: '🇯🇵' },
|
{ code: 'ja', label: 'JA', nativeName: '日本語', flag: '🇯🇵' },
|
||||||
{ code: 'zh', label: 'ZH', 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 {
|
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,
|
ExternalLink,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
Sparkles
|
Sparkles,
|
||||||
|
Globe
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
import { API_BASE, type Page, type PageSection, type Category } from '@homaroma/types';
|
||||||
import { getAdminDisplayString } from '../utils/localization';
|
import { getAdminDisplayString } from '../utils/localization';
|
||||||
import MediaLibraryModal from '../components/MediaLibraryModal';
|
import MediaLibraryModal from '../components/MediaLibraryModal';
|
||||||
|
import PageSeoModal from '../components/page-builder/PageSeoModal';
|
||||||
import { useStoreSettings } from '../contexts/StoreSettingsContext';
|
import { useStoreSettings } from '../contexts/StoreSettingsContext';
|
||||||
import { getAuthHeaders } from '../utils/auth';
|
import { getAuthHeaders } from '../utils/auth';
|
||||||
|
|
||||||
@@ -100,17 +102,20 @@ export default function PageBuilderPage() {
|
|||||||
const [brands, setBrands] = useState<Brand[]>([]);
|
const [brands, setBrands] = useState<Brand[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
|
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 [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||||
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
|
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
|
||||||
const [isCopied, setIsCopied] = useState(false);
|
const [isCopied, setIsCopied] = useState(false);
|
||||||
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
|
const [isReorderModalOpen, setIsReorderModalOpen] = useState(false);
|
||||||
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false);
|
const [isAddBlockModalOpen, setIsAddBlockModalOpen] = useState(false);
|
||||||
|
const [isSeoModalOpen, setIsSeoModalOpen] = useState(false);
|
||||||
|
|
||||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleMediaSelect = (url: string) => {
|
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;
|
const idx = uploadTarget.index;
|
||||||
setEditingSection(prev => {
|
setEditingSection(prev => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
@@ -423,12 +428,22 @@ export default function PageBuilderPage() {
|
|||||||
id: page.id,
|
id: page.id,
|
||||||
slug: page.slug,
|
slug: page.slug,
|
||||||
title: page.title,
|
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();
|
const data = await res.json();
|
||||||
if (res.ok && data && !data.error) {
|
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();
|
await fetchPagesList();
|
||||||
} else {
|
} else {
|
||||||
alert(data.error || t('pageBuilder.errSaveSettings', 'Failed to save page settings'));
|
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')}
|
<Plus className="w-4 h-4" /> {t('pageBuilder.createPageBtn', 'Create Page')}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1152,12 +1177,12 @@ export default function PageBuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleSavePageSettings}
|
onClick={handleSavePageSettings}
|
||||||
disabled={isSavingPageSettings}
|
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 ? (
|
{isSavingPageSettings ? (
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
@@ -1166,6 +1191,14 @@ export default function PageBuilderPage() {
|
|||||||
)}
|
)}
|
||||||
{t('pageBuilder.btnSaveSettings', 'Save Settings')}
|
{t('pageBuilder.btnSaveSettings', 'Save Settings')}
|
||||||
</button>
|
</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
|
<a
|
||||||
href={getPageUrl(page.slug)}
|
href={getPageUrl(page.slug)}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
@@ -1174,7 +1207,7 @@ export default function PageBuilderPage() {
|
|||||||
title={`Open ${page.title} live preview`}
|
title={`Open ${page.title} live preview`}
|
||||||
>
|
>
|
||||||
<ExternalLink className="w-4 h-4" />
|
<ExternalLink className="w-4 h-4" />
|
||||||
{t('pageBuilder.btnPreviewLive', 'Preview Live')}
|
{t('pageBuilder.btnPreviewLive', 'Preview')}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{page.slug !== 'home' && (
|
{page.slug !== 'home' && (
|
||||||
@@ -2049,6 +2082,20 @@ export default function PageBuilderPage() {
|
|||||||
onClose={() => setIsAddBlockModalOpen(false)}
|
onClose={() => setIsAddBlockModalOpen(false)}
|
||||||
onSelectBlock={handleSelectBlockFromModal}
|
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>
|
</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 { Edit3, Plus, Save, Settings2, Trash2, X } from 'lucide-react'
|
||||||
import ContentLanguageTabs from '../components/ContentLanguageTabs'
|
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
|
const ATTRIBUTE_TYPES = ['TEXT', 'NUMBER', 'BOOLEAN', 'SELECT', 'JSON'] as const
|
||||||
|
|
||||||
type EditableDefinition = {
|
type EditableDefinition = {
|
||||||
|
|||||||
@@ -1,133 +1,581 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react';
|
||||||
import { API_BASE, type StoreSettings } from '@homaroma/types'
|
import { API_BASE, type StoreSettings, type StoreSettingsTranslation } from '@homaroma/types';
|
||||||
import { Save, Loader2 } from 'lucide-react'
|
import {
|
||||||
import { useStoreSettings } from '../contexts/StoreSettingsContext'
|
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() {
|
export default function StoreSettingsPage() {
|
||||||
const { updateSettingsState } = useStoreSettings()
|
const { updateSettingsState } = useStoreSettings();
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
|
|
||||||
const [brandName, setBrandName] = useState('')
|
// Store Brand & Identity
|
||||||
const [logoUrl, setLogoUrl] = useState('')
|
const [brandName, setBrandName] = useState('');
|
||||||
const [themePreset, setThemePreset] = useState('default')
|
const [logoUrl, setLogoUrl] = useState('');
|
||||||
const [facebookUrl, setFacebookUrl] = useState('')
|
const [themePreset, setThemePreset] = useState('default');
|
||||||
const [instagramUrl, setInstagramUrl] = useState('')
|
const [facebookUrl, setFacebookUrl] = useState('');
|
||||||
const [tiktokUrl, setTiktokUrl] = 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(() => {
|
useEffect(() => {
|
||||||
fetch(`${API_BASE}/settings`)
|
fetch(`${API_BASE}/settings`)
|
||||||
.then(r => r.json())
|
.then((r) => r.json())
|
||||||
.then((data: StoreSettings) => {
|
.then((data: StoreSettings) => {
|
||||||
setBrandName(data.brandName || '')
|
setBrandName(data.brandName || '');
|
||||||
setLogoUrl(data.logoUrl || '')
|
setLogoUrl(data.logoUrl || '');
|
||||||
setThemePreset(data.themePreset || 'default')
|
setThemePreset(data.themePreset || 'default');
|
||||||
setFacebookUrl(data.facebookUrl || '')
|
setFacebookUrl(data.facebookUrl || '');
|
||||||
setInstagramUrl(data.instagramUrl || '')
|
setInstagramUrl(data.instagramUrl || '');
|
||||||
setTiktokUrl(data.tiktokUrl || '')
|
setTiktokUrl(data.tiktokUrl || '');
|
||||||
|
setDefaultSeoTitle(data.defaultSeoTitle || '');
|
||||||
|
setDefaultSeoDescription(data.defaultSeoDescription || '');
|
||||||
|
setDefaultOgImage(data.defaultOgImage || '');
|
||||||
|
setTranslations(data.translations || []);
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.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) => {
|
const handleSave = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setSaving(true)
|
setSaving(true);
|
||||||
|
setSaveSuccess(false);
|
||||||
|
|
||||||
try {
|
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`, {
|
const res = await fetch(`${API_BASE}/settings`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl }),
|
body: JSON.stringify(payload),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
updateSettingsState({ brandName, logoUrl, themePreset, facebookUrl, instagramUrl, tiktokUrl })
|
updateSettingsState(payload);
|
||||||
alert('Saved successfully!')
|
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 (
|
return (
|
||||||
<div className="max-w-3xl space-y-8 animate-fade-in">
|
<div className="max-w-4xl space-y-8 animate-fade-in text-slate-100 pb-16">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
<h1 className="text-3xl font-bold">Store Settings</h1>
|
<div className="space-y-1">
|
||||||
<p className="text-slate-400 text-sm">Brand identity, logo, theme preset and social media links.</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSave} className="space-y-8">
|
<form onSubmit={handleSave} className="space-y-8">
|
||||||
{/* Brand Identity */}
|
{/* 1. Brand Identity */}
|
||||||
<div className="admin-card p-8 space-y-6">
|
<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="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>
|
<div className="p-2 bg-purple-500/10 rounded-lg">
|
||||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Brand Identity</h2>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Brand Name</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<input type="text" required value={brandName} onChange={e => setBrandName(e.target.value)}
|
Brand Name
|
||||||
className="admin-input w-full max-w-sm" placeholder="Enter Brand Name" />
|
</label>
|
||||||
<p className="text-[10px] text-slate-400">Used in header, footer, and email communications.</p>
|
<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>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Logo URL</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<input type="url" value={logoUrl} onChange={e => setLogoUrl(e.target.value)}
|
Logo URL
|
||||||
className="admin-input w-full max-w-sm" placeholder="https://example.com/logo.png" />
|
</label>
|
||||||
<p className="text-[10px] text-slate-400">If provided, replaces the brand name text in headers.</p>
|
<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 && (
|
{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" />
|
<img src={logoUrl} alt="Logo Preview" className="h-8 object-contain" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Theme Preset</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<select value={themePreset} onChange={e => setThemePreset(e.target.value)}
|
Theme Preset
|
||||||
className="admin-input w-full max-w-sm bg-slate-900 border-white/10">
|
</label>
|
||||||
<option value="default">Default</option>
|
<select
|
||||||
<option value="warm">Warm</option>
|
value={themePreset}
|
||||||
<option value="minimal">Minimal</option>
|
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>
|
</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>
|
</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="admin-card p-8 space-y-6">
|
||||||
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
|
<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>
|
<div className="p-2 bg-cyan-glow/10 rounded-lg">
|
||||||
<h2 className="text-lg font-bold uppercase tracking-widest text-white">Social Media Links</h2>
|
<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>
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-6">
|
<div className="grid md:grid-cols-3 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Facebook URL</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<input type="text" value={facebookUrl} onChange={e => setFacebookUrl(e.target.value)}
|
Facebook URL
|
||||||
className="admin-input w-full" placeholder="https://facebook.com/..." />
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">Instagram URL</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<input type="text" value={instagramUrl} onChange={e => setInstagramUrl(e.target.value)}
|
Instagram URL
|
||||||
className="admin-input w-full" placeholder="https://instagram.com/..." />
|
</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>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">TikTok URL</label>
|
<label className="text-xs uppercase font-bold tracking-widest text-slate-400">
|
||||||
<input type="text" value={tiktokUrl} onChange={e => setTiktokUrl(e.target.value)}
|
TikTok URL
|
||||||
className="admin-input w-full" placeholder="https://tiktok.com/@..." />
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" disabled={saving}
|
<div className="flex items-center justify-between pt-4">
|
||||||
className="btn-primary py-4 px-12 flex items-center gap-2 ml-auto text-sm">
|
<p className="text-xs text-slate-400">
|
||||||
{saving ? <Loader2 className="w-4 h-4 animate-spin" /> : <Save className="w-4 h-4" />}
|
Clicking Save will update all store configuration, brand identity, and global SEO metadata.
|
||||||
Save Store Settings
|
</p>
|
||||||
</button>
|
<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>
|
</form>
|
||||||
|
|
||||||
|
<MediaLibraryModal
|
||||||
|
isOpen={isMediaModalOpen}
|
||||||
|
onClose={() => setIsMediaModalOpen(false)}
|
||||||
|
onSelect={handleMediaSelect}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
@@ -7,6 +7,9 @@ import { getAuthHeaders } from '../../utils/auth'
|
|||||||
|
|
||||||
type SaveState = { type: 'success' | 'error'; message: string } | null
|
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() {
|
export default function FooterBrandPage() {
|
||||||
const { updateSettingsState } = useStoreSettings()
|
const { updateSettingsState } = useStoreSettings()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
@@ -17,8 +20,8 @@ export default function FooterBrandPage() {
|
|||||||
const [facebookUrl, setFacebookUrl] = useState('')
|
const [facebookUrl, setFacebookUrl] = useState('')
|
||||||
const [instagramUrl, setInstagramUrl] = useState('')
|
const [instagramUrl, setInstagramUrl] = useState('')
|
||||||
const [tiktokUrl, setTiktokUrl] = useState('')
|
const [tiktokUrl, setTiktokUrl] = useState('')
|
||||||
const [description, setDescription] = useState<Record<string, string>>({ th: '', en: '' })
|
const [description, setDescription] = useState<Record<string, string>>({ th: '', en: '', ja: '', zh: '', vi: '', ko: '', lo: '', it: '' })
|
||||||
const [activeLanguage, setActiveLanguage] = useState<'th' | 'en'>('th')
|
const [activeLanguage, setActiveLanguage] = useState<FooterLanguage>('th')
|
||||||
const [version, setVersion] = useState<string | undefined>()
|
const [version, setVersion] = useState<string | undefined>()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,7 +105,31 @@ export default function FooterBrandPage() {
|
|||||||
<div className="grid gap-6 md:grid-cols-2">
|
<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-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 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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -673,8 +673,8 @@ export default function MenuEditorPage({ menuName, title, description }: MenuEdi
|
|||||||
{/* Translations */}
|
{/* Translations */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-[10px] uppercase font-bold text-slate-500">Translations</label>
|
<label className="text-[10px] uppercase font-bold text-slate-500">Translations</label>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||||
{['th', 'ja', 'zh', 'vi', 'ko'].map(lang => {
|
{languages.map(lang => {
|
||||||
const transVal = item.translations?.find((t: any) => t.langCode === lang)?.label || ''
|
const transVal = item.translations?.find((t: any) => t.langCode === lang)?.label || ''
|
||||||
return (
|
return (
|
||||||
<div key={lang} className="flex items-center gap-2">
|
<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")
|
scrollRevealStyle String @default("fade-up")
|
||||||
scrollRevealDuration Int @default(700)
|
scrollRevealDuration Int @default(700)
|
||||||
headerStyle String @default("single_line")
|
headerStyle String @default("single_line")
|
||||||
|
defaultSeoTitle String?
|
||||||
|
defaultSeoDescription String?
|
||||||
|
defaultOgImage String?
|
||||||
translations StoreSettingsTranslation[]
|
translations StoreSettingsTranslation[]
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -299,6 +302,8 @@ model StoreSettingsTranslation {
|
|||||||
newsletterDescription String?
|
newsletterDescription String?
|
||||||
newsletterPlaceholder String?
|
newsletterPlaceholder String?
|
||||||
newsletterButtonText String?
|
newsletterButtonText String?
|
||||||
|
defaultSeoTitle String?
|
||||||
|
defaultSeoDescription String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@ -660,24 +665,31 @@ model Review {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model Page {
|
model Page {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
slug String @unique // e.g., "home", "about-us"
|
slug String @unique // e.g., "home", "about-us"
|
||||||
title String
|
title String
|
||||||
sections PageSection[]
|
seoTitle String?
|
||||||
translations PageTranslation[]
|
seoDescription String?
|
||||||
isPublished Boolean @default(true)
|
seoKeywords String?
|
||||||
createdAt DateTime @default(now())
|
ogImageUrl String?
|
||||||
updatedAt DateTime @updatedAt
|
sections PageSection[]
|
||||||
|
translations PageTranslation[]
|
||||||
|
isPublished Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
model PageTranslation {
|
model PageTranslation {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
pageId Int
|
pageId Int
|
||||||
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
page Page @relation(fields: [pageId], references: [id], onDelete: Cascade)
|
||||||
langCode String
|
langCode String
|
||||||
title String
|
title String
|
||||||
createdAt DateTime @default(now())
|
seoTitle String?
|
||||||
updatedAt DateTime @updatedAt
|
seoDescription String?
|
||||||
|
seoKeywords String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@unique([pageId, langCode])
|
@@unique([pageId, langCode])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,100 @@ export function mergeSectionTranslations(payload: any, translations: any, paths:
|
|||||||
return { payload: result, translatedPaths };
|
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" })
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
.get("/", async ({ query }) => {
|
.get("/", async ({ query }) => {
|
||||||
const view = String((query as any)?.view || "summary").toLowerCase();
|
const view = String((query as any)?.view || "summary").toLowerCase();
|
||||||
@@ -230,6 +324,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
sections: {
|
sections: {
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
},
|
},
|
||||||
|
translations: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -239,6 +334,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
id: true,
|
id: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
title: true,
|
title: true,
|
||||||
|
seoTitle: true,
|
||||||
|
seoDescription: true,
|
||||||
|
seoKeywords: true,
|
||||||
|
ogImageUrl: true,
|
||||||
isPublished: true,
|
isPublished: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
updatedAt: true,
|
updatedAt: true,
|
||||||
@@ -262,6 +361,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
where: includeInactive ? undefined : { isActive: true },
|
where: includeInactive ? undefined : { isActive: true },
|
||||||
orderBy: { order: "asc" },
|
orderBy: { order: "asc" },
|
||||||
},
|
},
|
||||||
|
translations: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!page) {
|
if (!page) {
|
||||||
@@ -273,9 +373,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
page.sections.map(async (section: any) => await hydrateSectionPayload(section, lang))
|
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 {
|
return {
|
||||||
...page,
|
...page,
|
||||||
|
title,
|
||||||
|
seoTitle,
|
||||||
|
seoDescription,
|
||||||
|
seoKeywords,
|
||||||
|
ogImageUrl: page.ogImageUrl,
|
||||||
|
isPublished: page.isPublished,
|
||||||
sections: populatedSections,
|
sections: populatedSections,
|
||||||
|
translations: page.translations,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.get("/:slug/sections", async ({ params: { slug }, set }) => {
|
.get("/:slug/sections", async ({ params: { slug }, set }) => {
|
||||||
@@ -545,6 +658,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
title: string;
|
title: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
isPublished?: boolean;
|
isPublished?: boolean;
|
||||||
|
seoTitle?: string | null;
|
||||||
|
seoDescription?: string | null;
|
||||||
|
seoKeywords?: string | null;
|
||||||
|
ogImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
section: {
|
section: {
|
||||||
type: string;
|
type: string;
|
||||||
@@ -617,6 +734,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
title: pageInput.title,
|
title: pageInput.title,
|
||||||
slug: pageInput.slug,
|
slug: pageInput.slug,
|
||||||
isPublished: pageInput.isPublished ?? false,
|
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({
|
const section = await tx.pageSection.create({
|
||||||
@@ -661,6 +782,10 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
title: t.String(),
|
title: t.String(),
|
||||||
slug: t.String(),
|
slug: t.String(),
|
||||||
isPublished: t.Optional(t.Boolean()),
|
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({
|
section: t.Object({
|
||||||
type: t.String(),
|
type: t.String(),
|
||||||
@@ -677,7 +802,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
const data = body as any;
|
const data = body as any;
|
||||||
const operation = data?.id ? "update" : "create";
|
const operation = data?.id ? "update" : "create";
|
||||||
try {
|
try {
|
||||||
const { id, slug, title, isPublished, status, metadata } = data;
|
const { id, slug, title, isPublished, status, metadata, seoTitle, seoDescription, seoKeywords, ogImageUrl, translations } = data;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof slug === "string" &&
|
typeof slug === "string" &&
|
||||||
@@ -752,14 +877,41 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = id
|
let page: any;
|
||||||
? await prisma.page.update({
|
if (id) {
|
||||||
where: { id: Number(id) },
|
const updateData: any = { slug, title, isPublished: finalIsPublished };
|
||||||
data: { slug, title, isPublished: finalIsPublished },
|
if (seoTitle !== undefined) updateData.seoTitle = seoTitle;
|
||||||
})
|
if (seoDescription !== undefined) updateData.seoDescription = seoDescription;
|
||||||
: await prisma.page.create({
|
if (seoKeywords !== undefined) updateData.seoKeywords = seoKeywords;
|
||||||
data: { slug, title, isPublished: finalIsPublished },
|
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 {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
@@ -802,6 +954,80 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
slug: t.String(),
|
slug: t.String(),
|
||||||
title: t.String(),
|
title: t.String(),
|
||||||
isPublished: t.Optional(t.Boolean()),
|
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 }) => {
|
.patch("/:id/translations", async ({ params, body, set }) => {
|
||||||
const id = Number(params.id);
|
const id = Number(params.id);
|
||||||
if (isNaN(id)) {
|
if (isNaN(id) || id <= 0) {
|
||||||
set.status = 400;
|
set.status = 400;
|
||||||
return { error: "Invalid page ID" };
|
return { error: "Invalid page ID" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = body as any;
|
const results = await savePageTranslations(id, body);
|
||||||
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 updated = await prisma.page.findUnique({
|
const updated = await prisma.page.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -860,7 +1057,23 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
});
|
});
|
||||||
|
|
||||||
return { success: true, translations: results, page: updated };
|
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(
|
.post(
|
||||||
"/:slug/sections",
|
"/:slug/sections",
|
||||||
async ({ params: { slug }, body, set }) => {
|
async ({ params: { slug }, body, set }) => {
|
||||||
|
|||||||
@@ -2,6 +2,141 @@ import { Elysia, t } from "elysia";
|
|||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
import { adminOnly } from "../middleware/auth";
|
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" })
|
export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||||
.get("/", async ({ set, query }) => {
|
.get("/", async ({ set, query }) => {
|
||||||
try {
|
try {
|
||||||
@@ -13,19 +148,27 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const { translations, ...settingsData } = settings;
|
const { translations, ...settingsData } = settings;
|
||||||
const requestedLang = typeof query?.lang === "string" ? query.lang : undefined;
|
const requestedLang = typeof (query as any)?.lang === "string" ? (query as any).lang : undefined;
|
||||||
const localizedDescription = requestedLang
|
const translation = requestedLang
|
||||||
? translations.find((translation) => translation.langCode === requestedLang)?.footerDescription
|
? translations.find((t) => t.langCode === requestedLang)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const localizedDescription = translation?.footerDescription;
|
||||||
|
const localizedSeoTitle = translation?.defaultSeoTitle;
|
||||||
|
const localizedSeoDescription = translation?.defaultSeoDescription;
|
||||||
|
|
||||||
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
const headerBehavior = (settings.themeTokens as any)?.headerBehavior ?? 'hide_on_scroll';
|
||||||
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
|
const mobileHeaderBehavior = (settings.themeTokens as any)?.mobileHeaderBehavior ?? headerBehavior;
|
||||||
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||||
return {
|
return {
|
||||||
...settingsData,
|
...settingsData,
|
||||||
|
translations,
|
||||||
headerBehavior,
|
headerBehavior,
|
||||||
mobileHeaderBehavior,
|
mobileHeaderBehavior,
|
||||||
mobileHeaderStyle,
|
mobileHeaderStyle,
|
||||||
footerDescription: localizedDescription ?? settingsData.footerDescription,
|
footerDescription: localizedDescription ?? settingsData.footerDescription,
|
||||||
|
defaultSeoTitle: localizedSeoTitle ?? settingsData.defaultSeoTitle,
|
||||||
|
defaultSeoDescription: localizedSeoDescription ?? settingsData.defaultSeoDescription,
|
||||||
|
defaultOgImage: settingsData.defaultOgImage,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("[Settings] Error:", err);
|
console.error("[Settings] Error:", err);
|
||||||
@@ -67,7 +210,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.use(adminOnly)
|
.use(adminOnly)
|
||||||
.patch("/footer-brand", async ({ body, set }) => {
|
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
|
||||||
try {
|
try {
|
||||||
const payload = body as any;
|
const payload = body as any;
|
||||||
const settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
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" };
|
return { error: err?.message || "Failed to update footer brand" };
|
||||||
}
|
}
|
||||||
}, { body: t.Any() })
|
}, { body: t.Any() })
|
||||||
.put("/", async ({ body, set }) => {
|
.put("/", async ({ body, set }: { body: any; set: any }) => {
|
||||||
try {
|
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)
|
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
|
||||||
? data.themeTokens
|
? data.themeTokens
|
||||||
: {};
|
: {};
|
||||||
@@ -136,18 +279,27 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
}
|
}
|
||||||
data.themeTokens = updatedTokens;
|
data.themeTokens = updatedTokens;
|
||||||
|
|
||||||
let settings = await prisma.storeSettings.findFirst();
|
let settings = await prisma.storeSettings.findFirst({ include: { translations: true } });
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
settings = await prisma.storeSettings.create({ data });
|
settings = await prisma.storeSettings.create({ data, include: { translations: true } });
|
||||||
} else {
|
} else {
|
||||||
settings = await prisma.storeSettings.update({
|
settings = await prisma.storeSettings.update({
|
||||||
where: { id: settings.id },
|
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;
|
if (translations) {
|
||||||
const returnedMobileStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
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 {
|
return {
|
||||||
...settings,
|
...settings,
|
||||||
headerBehavior: returnedBehavior,
|
headerBehavior: returnedBehavior,
|
||||||
@@ -167,17 +319,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
bankAccountName: t.Optional(t.String()),
|
bankAccountName: t.Optional(t.String()),
|
||||||
bankAccountNumber: t.Optional(t.String()),
|
bankAccountNumber: t.Optional(t.String()),
|
||||||
pointsEarnRate: t.Optional(t.Number()),
|
pointsEarnRate: t.Optional(t.Number()),
|
||||||
logoUrl: t.Optional(t.String()),
|
logoUrl: t.Optional(t.Nullable(t.String())),
|
||||||
themePreset: t.Optional(t.String()),
|
themePreset: t.Optional(t.String()),
|
||||||
themeTokens: t.Optional(t.Any()),
|
themeTokens: t.Optional(t.Any()),
|
||||||
facebookUrl: t.Optional(t.String()),
|
facebookUrl: t.Optional(t.Nullable(t.String())),
|
||||||
instagramUrl: t.Optional(t.String()),
|
instagramUrl: t.Optional(t.Nullable(t.String())),
|
||||||
tiktokUrl: t.Optional(t.String()),
|
tiktokUrl: t.Optional(t.Nullable(t.String())),
|
||||||
footerDescription: t.Optional(t.String()),
|
footerDescription: t.Optional(t.Nullable(t.String())),
|
||||||
newsletterTitle: t.Optional(t.String()),
|
newsletterTitle: t.Optional(t.Nullable(t.String())),
|
||||||
newsletterDescription: t.Optional(t.String()),
|
newsletterDescription: t.Optional(t.Nullable(t.String())),
|
||||||
newsletterPlaceholder: t.Optional(t.String()),
|
newsletterPlaceholder: t.Optional(t.Nullable(t.String())),
|
||||||
newsletterButtonText: t.Optional(t.String()),
|
newsletterButtonText: t.Optional(t.Nullable(t.String())),
|
||||||
footerSupportHeading: t.Optional(t.Any()),
|
footerSupportHeading: t.Optional(t.Any()),
|
||||||
footerCopyrightText: t.Optional(t.Any()),
|
footerCopyrightText: t.Optional(t.Any()),
|
||||||
scrollRevealEnabled: t.Optional(t.Boolean()),
|
scrollRevealEnabled: t.Optional(t.Boolean()),
|
||||||
@@ -185,52 +337,106 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
scrollRevealDuration: t.Optional(t.Number()),
|
scrollRevealDuration: t.Optional(t.Number()),
|
||||||
headerStyle: t.Optional(t.String()),
|
headerStyle: t.Optional(t.String()),
|
||||||
headerBehavior: 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 {
|
try {
|
||||||
let settings = await prisma.storeSettings.findFirst();
|
let settings = await prisma.storeSettings.findFirst();
|
||||||
if (!settings) {
|
if (!settings) {
|
||||||
settings = await prisma.storeSettings.create({ data: {} });
|
settings = await prisma.storeSettings.create({ data: {} });
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = body as any;
|
const results = await saveStoreSettingsTranslations(settings.id, body);
|
||||||
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 updatedSettings = await prisma.storeSettings.findFirst({
|
const updatedSettings = await prisma.storeSettings.findFirst({
|
||||||
include: { translations: true }
|
include: { translations: true }
|
||||||
@@ -242,8 +448,28 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
|||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Failed to update settings translation" };
|
return { error: "Failed to update settings translation" };
|
||||||
}
|
}
|
||||||
})
|
}, { body: t.Any() })
|
||||||
.post("/seed-pages", async ({ set }) => {
|
.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 SectionInput = { type: string; order: number; isActive: boolean; payload: object };
|
||||||
type PageDef = { slug: string; title: string; sections: SectionInput[] };
|
type PageDef = { slug: string; title: string; sections: SectionInput[] };
|
||||||
|
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "update_store_settings_translation",
|
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: {
|
inputSchema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -402,7 +402,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
newsletterTitle: { type: "string" },
|
newsletterTitle: { type: "string" },
|
||||||
newsletterDescription: { type: "string" },
|
newsletterDescription: { type: "string" },
|
||||||
newsletterPlaceholder: { type: "string" },
|
newsletterPlaceholder: { type: "string" },
|
||||||
newsletterButtonText: { type: "string" }
|
newsletterButtonText: { type: "string" },
|
||||||
|
defaultSeoTitle: { type: "string" },
|
||||||
|
defaultSeoDescription: { type: "string" }
|
||||||
},
|
},
|
||||||
required: ["lang"]
|
required: ["lang"]
|
||||||
}
|
}
|
||||||
@@ -1388,13 +1390,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||||
}
|
}
|
||||||
case "update_store_settings_translation": {
|
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 = {};
|
const payload: any = {};
|
||||||
if (footerDescription) payload.footerDescription = { [lang]: footerDescription };
|
if (footerDescription !== undefined) payload.footerDescription = { [lang]: footerDescription };
|
||||||
if (newsletterTitle) payload.newsletterTitle = { [lang]: newsletterTitle };
|
if (newsletterTitle !== undefined) payload.newsletterTitle = { [lang]: newsletterTitle };
|
||||||
if (newsletterDescription) payload.newsletterDescription = { [lang]: newsletterDescription };
|
if (newsletterDescription !== undefined) payload.newsletterDescription = { [lang]: newsletterDescription };
|
||||||
if (newsletterPlaceholder) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
|
if (newsletterPlaceholder !== undefined) payload.newsletterPlaceholder = { [lang]: newsletterPlaceholder };
|
||||||
if (newsletterButtonText) payload.newsletterButtonText = { [lang]: newsletterButtonText };
|
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);
|
const res = await api.patch(`/settings/translations`, payload);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
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)
|
const pageResult = await getCmsPageResult(slug, lang)
|
||||||
if (!pageResult.ok || !pageResult.data.isPublished) return { title: 'Page Not Found' }
|
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({
|
return generateSeoMetadata({
|
||||||
title: `${pageResult.data.title}`,
|
title,
|
||||||
description: `${pageResult.data.title}`,
|
description,
|
||||||
path: `/${slug}`,
|
path: `/${slug}`,
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: ogImage
|
||||||
|
? {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
images: [ogImage],
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,24 @@
|
|||||||
|
import type { Metadata } from 'next'
|
||||||
import Link from '@/components/LocalizedLink'
|
import Link from '@/components/LocalizedLink'
|
||||||
import { API_BASE } from '@/lib/api'
|
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 = [
|
const PILLARS = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { Category, Product } from '@homaroma/types'
|
|||||||
import { getCategoryImage } from '@homaroma/types'
|
import { getCategoryImage } from '@homaroma/types'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '@/components/ProductCard'
|
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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '@/lib/localization'
|
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> {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { id, lang } = await params
|
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 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({
|
return generateSeoMetadata({
|
||||||
title: categoryName ? `${categoryName}` : 'Category',
|
title: categoryName ? `${categoryName}` : 'Category',
|
||||||
description: categoryName ? `Shop ${categoryName} products from .` : 'Shop category products.',
|
description,
|
||||||
path: `/category/${id}`,
|
path: `/category/${id}`,
|
||||||
lang,
|
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 CheckoutClient from '@/components/CheckoutClient'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
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'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
// lang is extracted from params
|
// lang is extracted from params
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const settings = await getStoreSettings(lang);
|
||||||
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = brandName
|
||||||
|
? `ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ${brandName}`
|
||||||
|
: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ';
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: 'Checkout',
|
title: 'Checkout',
|
||||||
description: 'ตรวจสอบรายการสินค้า กรอกข้อมูลจัดส่ง และสร้างคำสั่งซื้อ ',
|
description,
|
||||||
path: '/checkout',
|
path: '/checkout',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
|
import { getStoreSettings } from '@/lib/cms'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||||
title: 'Contact Us',
|
const { lang } = await params
|
||||||
description: 'Get in touch with . We respond to all inquiries within 2 hours.',
|
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 }) {
|
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import Link from '@/components/LocalizedLink'
|
|||||||
import { Ticket, ArrowRight } from 'lucide-react'
|
import { Ticket, ArrowRight } from 'lucide-react'
|
||||||
import { generateSeoMetadata } from '@/lib/seo'
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { toneClass } from '@/lib/theme'
|
import { toneClass } from '@/lib/theme'
|
||||||
|
|
||||||
@@ -16,11 +16,18 @@ function formatDiscount(type: string, value: number) {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const settings = await getStoreSettings(lang);
|
||||||
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = brandName
|
||||||
|
? `รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก ${brandName} สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ`
|
||||||
|
: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษสำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ';
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: 'โปรโมชั่นและคูปองส่วนลด',
|
title: 'โปรโมชั่นและคูปองส่วนลด',
|
||||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
description,
|
||||||
path: '/coupons',
|
path: '/coupons',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
|
import { getStoreSettings } from '@/lib/cms'
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }): Promise<Metadata> {
|
||||||
title: 'FAQ',
|
const { lang } = await params
|
||||||
description: 'Everything you need to know about products, shipping, and care.',
|
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 }) {
|
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 { lang } = await params;
|
||||||
const settings = await getStoreSettings(lang);
|
const settings = await getStoreSettings(lang);
|
||||||
const brandName = settings?.brandName || '';
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = settings?.defaultSeoDescription || settings?.footerDescription || 'Pure Origins, Artisan Craft, Zero Compromise';
|
||||||
|
const ogImage = settings?.defaultOgImage;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: {
|
title: {
|
||||||
template: `%s | ${brandName}`,
|
template: `%s | ${brandName}`,
|
||||||
default: brandName,
|
default: brandName,
|
||||||
},
|
},
|
||||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
description,
|
||||||
metadataBase: new URL(process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'),
|
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 { formatPrice } from '@homaroma/types'
|
||||||
|
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { orderStatusBadgeClass, toneClass } from '@/lib/theme'
|
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> {
|
export async function generateMetadata({ params }: { params: Promise<{ id: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { id, lang } = await params
|
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' }
|
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({
|
return generateSeoMetadata({
|
||||||
title: `Order ${orderLabel(result.data.id)}`,
|
title: `Order ${orderLabel(result.data.id)}`,
|
||||||
description: `ตรวจสอบสถานะคำสั่งซื้อ ${orderLabel(result.data.id)} จาก `,
|
description,
|
||||||
path: `/order/${id}`,
|
path: `/order/${id}`,
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { Metadata } from 'next'
|
import type { Metadata } from 'next'
|
||||||
import { generateSeoMetadata } from '@/lib/seo'
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
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'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,13 +10,30 @@ type Props = {
|
|||||||
|
|
||||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||||
const { lang } = await params
|
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({
|
return generateSeoMetadata({
|
||||||
title: page?.title ? `${page.title}` : undefined,
|
title,
|
||||||
description: 'Premium aromatherapy, home fragrance, and botanical scent products from .',
|
description,
|
||||||
path: '',
|
path: '',
|
||||||
lang,
|
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 ProductStickyPurchaseBar from '@/components/ProductStickyPurchaseBar'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '@/components/ProductCard'
|
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 { resolveCategoryName, resolveLocalized } from '@/lib/localization'
|
||||||
import { SUPPORTED_LOCALES } from '@/lib/locales'
|
import { SUPPORTED_LOCALES } from '@/lib/locales'
|
||||||
import { extractIdFromSlug, generateProductSlug } from '@/lib/slugify'
|
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> {
|
export async function generateMetadata({ params }: { params: Promise<{ slug: string, lang: string }> }): Promise<Metadata> {
|
||||||
const { slug, lang } = await params
|
const { slug, lang } = await params
|
||||||
const id = extractIdFromSlug(slug)
|
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' }
|
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 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 baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://www.homaroma.com'
|
||||||
const customLanguages: Record<string, string> = {}
|
const customLanguages: Record<string, string> = {}
|
||||||
for (const l of SUPPORTED_LOCALES) {
|
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)}`
|
customLanguages[l] = `${baseUrl}/${l}/products/${generateProductSlug(locName, product.id)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ogImage = product.images?.[0]?.url || settings?.defaultOgImage
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
|
title: `${productName} ${categoryName ? `| ${categoryName}` : ''}`,
|
||||||
description: description || `Premium ${categoryName} from .`,
|
description,
|
||||||
path: `/products/${slug}`,
|
path: `/products/${slug}`,
|
||||||
lang,
|
lang,
|
||||||
customLanguages,
|
customLanguages,
|
||||||
@@ -377,7 +385,7 @@ export async function generateMetadata({ params }: { params: Promise<{ slug: str
|
|||||||
type: 'website',
|
type: 'website',
|
||||||
title: productName,
|
title: productName,
|
||||||
description,
|
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 DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProfileClient from '@/components/ProfileClient'
|
import ProfileClient from '@/components/ProfileClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
// lang is extracted from params
|
// lang is extracted from params
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const settings = await getStoreSettings(lang);
|
||||||
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = brandName
|
||||||
|
? `จัดการโปรไฟล์สมาชิก ${brandName} ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง`
|
||||||
|
: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง';
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: 'โปรไฟล์สมาชิก',
|
title: 'โปรไฟล์สมาชิก',
|
||||||
description: 'จัดการโปรไฟล์สมาชิก ประวัติคำสั่งซื้อ คะแนนสะสม และสมุดที่อยู่จัดส่ง',
|
description,
|
||||||
path: '/profile',
|
path: '/profile',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,17 +2,24 @@ import Link from '@/components/LocalizedLink'
|
|||||||
import { generateSeoMetadata } from '@/lib/seo'
|
import { generateSeoMetadata } from '@/lib/seo'
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '@/components/ProductCard'
|
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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '@/lib/localization'
|
import { resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
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({
|
return generateSeoMetadata({
|
||||||
title: 'Search Products',
|
title: 'Search Products',
|
||||||
description: 'Search products by scent, category, or keyword.',
|
description,
|
||||||
path: '/search',
|
path: '/search',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,18 +3,25 @@ import { generateSeoMetadata } from '@/lib/seo'
|
|||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import ProductCard from '@/components/ProductCard'
|
import ProductCard from '@/components/ProductCard'
|
||||||
import ShopCouponBridge from '@/components/ShopCouponBridge'
|
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 { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
import { resolveLocalized } from '@/lib/localization'
|
import { resolveLocalized } from '@/lib/localization'
|
||||||
|
|
||||||
// lang is extracted from params
|
// lang is extracted from params
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
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({
|
return generateSeoMetadata({
|
||||||
title: 'Shop Aroma Products',
|
title: 'Shop Aroma Products',
|
||||||
description: 'Browse aroma oils, reed diffusers, room sprays, and premium scent products.',
|
description,
|
||||||
path: '/shop',
|
path: '/shop',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
|
|||||||
|
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import TrackOrderClient from '@/components/TrackOrderClient'
|
import TrackOrderClient from '@/components/TrackOrderClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
// lang is extracted from params
|
// lang is extracted from params
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const settings = await getStoreSettings(lang);
|
||||||
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = brandName
|
||||||
|
? `ตรวจสอบสถานะคำสั่งซื้อ ${brandName} ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ`
|
||||||
|
: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ';
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: 'ติดตามคำสั่งซื้อ',
|
title: 'ติดตามคำสั่งซื้อ',
|
||||||
description: 'ตรวจสอบสถานะคำสั่งซื้อ ด้วยหมายเลขคำสั่งซื้อและเบอร์โทรศัพท์ที่ใช้สั่งซื้อ',
|
description,
|
||||||
path: '/track',
|
path: '/track',
|
||||||
lang,
|
lang,
|
||||||
|
openGraph: settings?.defaultOgImage ? { images: [settings.defaultOgImage] } : undefined,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,17 +3,24 @@ import { generateSeoMetadata } from '@/lib/seo'
|
|||||||
|
|
||||||
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '@/components/dynamic/DynamicPageRenderer'
|
||||||
import WishlistClient from '@/components/WishlistClient'
|
import WishlistClient from '@/components/WishlistClient'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '@/lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getStoreSettings } from '@/lib/cms'
|
||||||
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
import { splitCmsLayoutSections } from '@/lib/cms-sections'
|
||||||
|
|
||||||
// lang is extracted from params
|
// lang is extracted from params
|
||||||
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
export async function generateMetadata({ params }: { params: Promise<{ lang: string }> }) {
|
||||||
const { lang } = await params;
|
const { lang } = await params;
|
||||||
|
const settings = await getStoreSettings(lang);
|
||||||
|
const brandName = settings?.brandName || '';
|
||||||
|
const description = brandName
|
||||||
|
? `รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ${brandName}`
|
||||||
|
: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist';
|
||||||
|
|
||||||
return generateSeoMetadata({
|
return generateSeoMetadata({
|
||||||
title: 'Wishlist',
|
title: 'Wishlist',
|
||||||
description: 'รายการสินค้าที่คุณบันทึกไว้ใน Wishlist ของ ',
|
description,
|
||||||
path: '/wishlist',
|
path: '/wishlist',
|
||||||
lang,
|
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 } })
|
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>> {
|
export async function getStoreSettingsResult(lang = 'all'): Promise<ApiResult<StoreSettings>> {
|
||||||
return await fetchJsonResult<StoreSettings>(`/settings?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJsonResult<StoreSettings>(`/settings?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,21 @@ export interface Hero {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StoreSettingsTranslation {
|
||||||
|
id: number
|
||||||
|
storeSettingsId: number
|
||||||
|
langCode: string
|
||||||
|
footerDescription?: string | null
|
||||||
|
newsletterTitle?: string | null
|
||||||
|
newsletterDescription?: string | null
|
||||||
|
newsletterPlaceholder?: string | null
|
||||||
|
newsletterButtonText?: string | null
|
||||||
|
defaultSeoTitle?: string | null
|
||||||
|
defaultSeoDescription?: string | null
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoreSettings {
|
export interface StoreSettings {
|
||||||
id: number
|
id: number
|
||||||
brandName: string
|
brandName: string
|
||||||
@@ -199,6 +214,10 @@ export interface StoreSettings {
|
|||||||
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
headerBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
||||||
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
|
mobileHeaderStyle?: 'logo_center' | 'logo_left' | 'search_bar_below' | string | null
|
||||||
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
mobileHeaderBehavior?: 'always_sticky' | 'hide_on_scroll' | string | null
|
||||||
|
defaultSeoTitle?: string | null
|
||||||
|
defaultSeoDescription?: string | null
|
||||||
|
defaultOgImage?: string | null
|
||||||
|
translations?: StoreSettingsTranslation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FooterBrand {
|
export interface FooterBrand {
|
||||||
@@ -307,12 +326,29 @@ export interface CartItem {
|
|||||||
productImage?: string | null
|
productImage?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PageTranslation {
|
||||||
|
id: number
|
||||||
|
pageId: number
|
||||||
|
langCode: string
|
||||||
|
title: string
|
||||||
|
seoTitle?: string | null
|
||||||
|
seoDescription?: string | null
|
||||||
|
seoKeywords?: string | null
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Page {
|
export interface Page {
|
||||||
id: number
|
id: number
|
||||||
slug: string
|
slug: string
|
||||||
title: string
|
title: string
|
||||||
|
seoTitle?: string | null
|
||||||
|
seoDescription?: string | null
|
||||||
|
seoKeywords?: string | null
|
||||||
|
ogImageUrl?: string | null
|
||||||
isPublished: boolean
|
isPublished: boolean
|
||||||
sections?: PageSection[]
|
sections?: PageSection[]
|
||||||
|
translations?: PageTranslation[]
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user