This commit is contained in:
Your Name committed 2026-09-29 07:40:08 +07:00
1 parent 602eaa9260
commit c42dd4272f
30 files changed
+2024 -252

No files matched your search

@@ -12,6 +12,10 @@ export const DEFAULT_CONTENT_LANGUAGES: LanguageOption[] = [
{ code: 'en', label: 'EN', nativeName: 'English', flag: '🇬🇧' }, { code: '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>
);
}
+55 -8
View File
@@ -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 = {
+521 -73
View File
@@ -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");
+27 -15
View File
@@ -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])
} }
+254 -41
View File
@@ -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 }) => {
+287 -61
View File
@@ -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[] };
+12 -8
View File
@@ -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) }] };
} }
+14 -2
View File
@@ -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,
}); });
} }
+17 -3
View File
@@ -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 }) {
+9 -2
View File
@@ -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,
}); });
} }
+17 -3
View File
@@ -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 }) {
+10 -1
View File
@@ -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,
}) })
} }
+21 -4
View File
@@ -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] : [],
}, },
}) })
} }
+9 -2
View File
@@ -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,
}); });
} }
+9 -2
View File
@@ -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,
}); });
} }
+9 -2
View File
@@ -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,
}); });
} }
+9 -2
View File
@@ -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,
}); });
} }
+4
View File
@@ -82,6 +82,10 @@ export async function getCategoriesResult(lang = 'all'): Promise<ApiResult<Categ
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 0 } }) 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 } })
} }
+36
View File
@@ -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
} }