fix update Section Block
This commit is contained in:
1 parent
996dfdabd0
commit
6f67ee6077
47 files changed
+1792
-197
No files matched your search
@@ -0,0 +1,13 @@
|
|||||||
|
[mcp_servers.homaroma_mcp]
|
||||||
|
command = "/home/ti/.bun/bin/bun"
|
||||||
|
args = ["index.ts"]
|
||||||
|
cwd = "/home/ti/git/homaroma/apps/mcp-server"
|
||||||
|
startup_timeout_sec = 20
|
||||||
|
tool_timeout_sec = 60
|
||||||
|
enabled = true
|
||||||
|
required = false
|
||||||
|
default_tools_approval_mode = "prompt"
|
||||||
|
|
||||||
|
[mcp_servers.homaroma_mcp.env]
|
||||||
|
MCP_BACKEND_URL_LOCAL = "http://localhost:3051/api"
|
||||||
|
MCP_BACKEND_URL_PRODUCTION = "https://api.homaroma.shop/api"
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { UploadCloud, Image as ImageIcon } from 'lucide-react';
|
||||||
|
import { type PageSection } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface HeroArticleFormProps {
|
||||||
|
editingSection: Partial<PageSection>;
|
||||||
|
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||||
|
editingLang: string;
|
||||||
|
getLocalizedValue: (payload: any, field: string) => string;
|
||||||
|
updateLocalizedValue: (field: string, value: string) => void;
|
||||||
|
setUploadTarget: (target: { type: 'main' | 'slide'; index?: number } | null) => void;
|
||||||
|
setIsMediaModalOpen: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function HeroArticleForm({
|
||||||
|
editingSection,
|
||||||
|
setEditingSection,
|
||||||
|
getLocalizedValue,
|
||||||
|
updateLocalizedValue,
|
||||||
|
setUploadTarget,
|
||||||
|
setIsMediaModalOpen,
|
||||||
|
}: HeroArticleFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Article Hero Image</label>
|
||||||
|
<div
|
||||||
|
className="relative aspect-video rounded-2xl overflow-hidden border-2 border-dashed border-white/10 bg-night-950/50 flex flex-col items-center justify-center group cursor-pointer hover:border-cyan-glow/30 transition-all"
|
||||||
|
onClick={() => {
|
||||||
|
setUploadTarget({ type: 'main' });
|
||||||
|
setIsMediaModalOpen(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{editingSection.payload?.imageUrl ? (
|
||||||
|
<>
|
||||||
|
<img src={editingSection.payload.imageUrl} className="w-full h-full object-cover" alt="Hero Article" />
|
||||||
|
<div className="absolute inset-0 bg-night-950/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center backdrop-blur-sm">
|
||||||
|
<UploadCloud className="w-10 h-10 text-white" />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center space-y-2">
|
||||||
|
<ImageIcon className="w-10 h-10 text-slate-700 mx-auto" />
|
||||||
|
<p className="text-xs text-slate-500 font-bold uppercase tracking-widest">Drop in an image URL first</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||||
|
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Editorial title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Subtitle</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'subtitle')}
|
||||||
|
onChange={(e) => updateLocalizedValue('subtitle', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Short supporting line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Author</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'author')}
|
||||||
|
onChange={(e) => updateLocalizedValue('author', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Author name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Category / Tag</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'category')}
|
||||||
|
onChange={(e) => updateLocalizedValue('category', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Story"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Publish Date</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingSection.payload?.publishDate || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), publishDate: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="2026-01-01"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Overlay Color</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingSection.payload?.overlayColor || '#00000080'}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), overlayColor: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="#00000080"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image URL</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={editingSection.payload?.imageUrl || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), imageUrl: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="/images/hero.png or select from media library"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { type PageSection } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface ProductCarouselFormProps {
|
||||||
|
editingSection: Partial<PageSection>;
|
||||||
|
editingLang: string;
|
||||||
|
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||||
|
getLocalizedValue: (payload: any, field: string) => string;
|
||||||
|
updateLocalizedValue: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductCarouselForm({
|
||||||
|
editingSection,
|
||||||
|
setEditingSection,
|
||||||
|
getLocalizedValue,
|
||||||
|
updateLocalizedValue,
|
||||||
|
}: ProductCarouselFormProps) {
|
||||||
|
const updateFlag = (field: 'showPrice' | 'showAddToCart', value: boolean) => {
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), [field]: value }
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||||
|
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Carousel heading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||||
|
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional supporting line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Collection ID</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={editingSection.payload?.collectionId || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), collectionId: e.target.value }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional collection identifier"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Display Limit</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={editingSection.payload?.limit || 6}
|
||||||
|
onChange={(e) =>
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), limit: parseInt(e.target.value, 10) }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="admin-input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingSection.payload?.showPrice ?? false}
|
||||||
|
onChange={(e) => updateFlag('showPrice', e.target.checked)}
|
||||||
|
className="accent-cyan-glow"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-slate-300">Show Price</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-3 p-3 border border-white/5 bg-white/5 rounded-xl cursor-pointer select-none">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={editingSection.payload?.showAddToCart ?? false}
|
||||||
|
onChange={(e) => updateFlag('showAddToCart', e.target.checked)}
|
||||||
|
className="accent-cyan-glow"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-bold text-slate-300">Show Add to Cart</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
If your backend supports collection lookup by ID, place that value here. Otherwise leave it blank and use the default product list behavior.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { type PageSection } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface QuoteCalloutFormProps {
|
||||||
|
editingSection: Partial<PageSection>;
|
||||||
|
editingLang: string;
|
||||||
|
getLocalizedValue: (payload: any, field: string) => string;
|
||||||
|
updateLocalizedValue: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuoteCalloutForm({
|
||||||
|
editingSection,
|
||||||
|
getLocalizedValue,
|
||||||
|
updateLocalizedValue,
|
||||||
|
}: QuoteCalloutFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="md:col-span-2 space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Quote</label>
|
||||||
|
<textarea
|
||||||
|
rows={5}
|
||||||
|
required
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'quote')}
|
||||||
|
onChange={(e) => updateLocalizedValue('quote', e.target.value)}
|
||||||
|
className="admin-input w-full resize-y"
|
||||||
|
placeholder="The quote to highlight..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Author</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'author')}
|
||||||
|
onChange={(e) => updateLocalizedValue('author', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional author"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Role</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'role')}
|
||||||
|
onChange={(e) => updateLocalizedValue('role', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional role or title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { type PageSection } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface RichTextFormProps {
|
||||||
|
editingSection: Partial<PageSection>;
|
||||||
|
editingLang: string;
|
||||||
|
getLocalizedValue: (payload: any, field: string) => string;
|
||||||
|
updateLocalizedValue: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RichTextForm({
|
||||||
|
editingSection,
|
||||||
|
getLocalizedValue,
|
||||||
|
updateLocalizedValue,
|
||||||
|
}: RichTextFormProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Section Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||||
|
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional title above the content"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Content</label>
|
||||||
|
<textarea
|
||||||
|
rows={10}
|
||||||
|
required
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'content')}
|
||||||
|
onChange={(e) => updateLocalizedValue('content', e.target.value)}
|
||||||
|
className="admin-input w-full resize-y"
|
||||||
|
placeholder="<p>Write HTML or plain text here...</p>"
|
||||||
|
/>
|
||||||
|
<p className="text-[9px] text-slate-500 font-medium">
|
||||||
|
HTML is allowed. Use the language tabs to translate each version.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Plus, Trash2 } from 'lucide-react';
|
||||||
|
import { type PageSection } from '@homaroma/types';
|
||||||
|
|
||||||
|
interface ScentNotesBreakdownFormProps {
|
||||||
|
editingSection: Partial<PageSection>;
|
||||||
|
setEditingSection: React.Dispatch<React.SetStateAction<Partial<PageSection> | null>>;
|
||||||
|
editingLang: string;
|
||||||
|
getLocalizedValue: (payload: any, field: string) => string;
|
||||||
|
updateLocalizedValue: (field: string, value: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NoteField = 'topNotes' | 'heartNotes' | 'baseNotes';
|
||||||
|
|
||||||
|
function getLocalizedListValue(item: any, lang: string) {
|
||||||
|
if (item === null || item === undefined) return '';
|
||||||
|
if (typeof item === 'string') return lang === 'th' ? item : '';
|
||||||
|
return item[lang] || item.en || item.th || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeList(source: any) {
|
||||||
|
return Array.isArray(source) ? source : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ScentNotesBreakdownForm({
|
||||||
|
editingSection,
|
||||||
|
setEditingSection,
|
||||||
|
editingLang,
|
||||||
|
getLocalizedValue,
|
||||||
|
updateLocalizedValue,
|
||||||
|
}: ScentNotesBreakdownFormProps) {
|
||||||
|
const syncNotes = (nextNotes: any[]) => {
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: {
|
||||||
|
...(editingSection.payload || {}),
|
||||||
|
heartNotes: nextNotes,
|
||||||
|
middleNotes: nextNotes,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getNotesForField = (field: NoteField) => {
|
||||||
|
if (field === 'heartNotes') {
|
||||||
|
return normalizeList(editingSection.payload?.heartNotes ?? editingSection.payload?.middleNotes);
|
||||||
|
}
|
||||||
|
return normalizeList(editingSection.payload?.[field]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateNote = (field: NoteField, index: number, value: string) => {
|
||||||
|
const notes = getNotesForField(field);
|
||||||
|
const nextNotes = [...notes];
|
||||||
|
const current = nextNotes[index];
|
||||||
|
nextNotes[index] = typeof current === 'string'
|
||||||
|
? { th: current, [editingLang]: value }
|
||||||
|
: { ...(current || {}), [editingLang]: value };
|
||||||
|
if (field === 'heartNotes') {
|
||||||
|
syncNotes(nextNotes);
|
||||||
|
} else {
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), [field]: nextNotes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNote = (field: NoteField) => {
|
||||||
|
const notes = getNotesForField(field);
|
||||||
|
const nextNotes = [...notes, { th: 'New note' }];
|
||||||
|
if (field === 'heartNotes') {
|
||||||
|
syncNotes(nextNotes);
|
||||||
|
} else {
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), [field]: nextNotes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeNote = (field: NoteField, index: number) => {
|
||||||
|
const notes = getNotesForField(field);
|
||||||
|
const nextNotes = notes.filter((_: any, idx: number) => idx !== index);
|
||||||
|
if (field === 'heartNotes') {
|
||||||
|
syncNotes(nextNotes);
|
||||||
|
} else {
|
||||||
|
setEditingSection({
|
||||||
|
...editingSection,
|
||||||
|
payload: { ...(editingSection.payload || {}), [field]: nextNotes }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderNotes = (field: NoteField, label: string) => {
|
||||||
|
const notes = getNotesForField(field);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3 rounded-2xl border border-white/5 bg-white/[0.02] p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-bold uppercase tracking-widest text-slate-400">{label}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addNote(field)}
|
||||||
|
className="text-xs text-cyan-glow hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-3.5 h-3.5" /> Add Note
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{notes.map((note: any, index: number) => (
|
||||||
|
<div key={index} className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedListValue(note, editingLang)}
|
||||||
|
onChange={(e) => updateNote(field, index, e.target.value)}
|
||||||
|
className="admin-input flex-1"
|
||||||
|
placeholder={`Note ${index + 1}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeNote(field, index)}
|
||||||
|
className="p-3 bg-rose-500/10 border border-rose-500/10 rounded-xl text-rose-500 hover:bg-rose-500 hover:text-white transition-all"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{notes.length === 0 && (
|
||||||
|
<p className="text-xs text-slate-500 italic">No notes added yet. Click Add Note above.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Title</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'title')}
|
||||||
|
onChange={(e) => updateLocalizedValue('title', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Scent notes heading"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Description</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getLocalizedValue(editingSection.payload, 'description')}
|
||||||
|
onChange={(e) => updateLocalizedValue('description', e.target.value)}
|
||||||
|
className="admin-input w-full"
|
||||||
|
placeholder="Optional supporting line"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{renderNotes('topNotes', 'Top Notes')}
|
||||||
|
{renderNotes('heartNotes', 'Heart / Middle Notes')}
|
||||||
|
{renderNotes('baseNotes', 'Base Notes')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ import CategoriesForm from '../components/page-builder/CategoriesForm';
|
|||||||
import ProductsForm from '../components/page-builder/ProductsForm';
|
import ProductsForm from '../components/page-builder/ProductsForm';
|
||||||
import BannerForm from '../components/page-builder/BannerForm';
|
import BannerForm from '../components/page-builder/BannerForm';
|
||||||
import TextForm from '../components/page-builder/TextForm';
|
import TextForm from '../components/page-builder/TextForm';
|
||||||
|
import HeroArticleForm from '../components/page-builder/HeroArticleForm';
|
||||||
|
import RichTextForm from '../components/page-builder/RichTextForm';
|
||||||
|
import ScentNotesBreakdownForm from '../components/page-builder/ScentNotesBreakdownForm';
|
||||||
|
import QuoteCalloutForm from '../components/page-builder/QuoteCalloutForm';
|
||||||
|
import ProductCarouselForm from '../components/page-builder/ProductCarouselForm';
|
||||||
import AccordionForm from '../components/page-builder/AccordionForm';
|
import AccordionForm from '../components/page-builder/AccordionForm';
|
||||||
import ContactInfoForm from '../components/page-builder/ContactInfoForm';
|
import ContactInfoForm from '../components/page-builder/ContactInfoForm';
|
||||||
import ContactFormForm from '../components/page-builder/ContactFormForm';
|
import ContactFormForm from '../components/page-builder/ContactFormForm';
|
||||||
@@ -531,6 +536,50 @@ export default function PageBuilderPage() {
|
|||||||
textAlign: 'center'
|
textAlign: 'center'
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case 'HERO_ARTICLE':
|
||||||
|
defaultPayload = {
|
||||||
|
title: 'Scent Guide [Article]',
|
||||||
|
subtitle: 'Discover the story behind each fragrance.',
|
||||||
|
author: 'HomAroma',
|
||||||
|
category: 'Story',
|
||||||
|
publishDate: '2026-01-01',
|
||||||
|
imageUrl: '/images/hero.png',
|
||||||
|
overlayColor: '#00000080'
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'RICH_TEXT':
|
||||||
|
defaultPayload = {
|
||||||
|
title: 'Brand Story',
|
||||||
|
content: '<p>Tell the story of your fragrance collection here.</p>',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'SCENT_NOTES_BREAKDOWN':
|
||||||
|
defaultPayload = {
|
||||||
|
title: 'Scent Notes',
|
||||||
|
description: 'Break down the fragrance profile into key layers.',
|
||||||
|
topNotes: ['Citrus', 'Green'],
|
||||||
|
heartNotes: ['Jasmine', 'Rose'],
|
||||||
|
middleNotes: ['Jasmine', 'Rose'],
|
||||||
|
baseNotes: ['Musk', 'Amber'],
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'QUOTE_CALLOUT':
|
||||||
|
defaultPayload = {
|
||||||
|
quote: 'Fragrance is the invisible, unforgettable accessory.',
|
||||||
|
author: 'HomAroma',
|
||||||
|
role: 'Founder',
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'PRODUCT_CAROUSEL':
|
||||||
|
defaultPayload = {
|
||||||
|
title: 'Featured Collection',
|
||||||
|
description: 'Hand-picked products for this campaign.',
|
||||||
|
collectionId: '',
|
||||||
|
limit: 6,
|
||||||
|
showPrice: true,
|
||||||
|
showAddToCart: true,
|
||||||
|
};
|
||||||
|
break;
|
||||||
case 'ACCORDION':
|
case 'ACCORDION':
|
||||||
defaultPayload = {
|
defaultPayload = {
|
||||||
title: 'Frequently Asked Questions',
|
title: 'Frequently Asked Questions',
|
||||||
@@ -791,7 +840,12 @@ export default function PageBuilderPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-base font-bold mt-1">
|
<h3 className="text-base font-bold mt-1">
|
||||||
{getAdminDisplayString(section.payload?.title || section.payload?.tagline || (section.type === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)) || `Block #${section.id}`}
|
{getAdminDisplayString(
|
||||||
|
section.payload?.title
|
||||||
|
|| section.payload?.tagline
|
||||||
|
|| section.payload?.quote
|
||||||
|
|| (section.type === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)
|
||||||
|
) || `Block #${section.id}`}
|
||||||
</h3>
|
</h3>
|
||||||
{section.type === 'PRODUCTS' && (
|
{section.type === 'PRODUCTS' && (
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
@@ -896,6 +950,11 @@ export default function PageBuilderPage() {
|
|||||||
<option value="PRODUCTS">PRODUCTS (Product list Showcase)</option>
|
<option value="PRODUCTS">PRODUCTS (Product list Showcase)</option>
|
||||||
<option value="BANNER">BANNER (Promo Image call-to-action)</option>
|
<option value="BANNER">BANNER (Promo Image call-to-action)</option>
|
||||||
<option value="TEXT">TEXT (About / Brand story text)</option>
|
<option value="TEXT">TEXT (About / Brand story text)</option>
|
||||||
|
<option value="HERO_ARTICLE">HERO ARTICLE (Editorial hero)</option>
|
||||||
|
<option value="RICH_TEXT">RICH TEXT (Markdown / HTML block)</option>
|
||||||
|
<option value="SCENT_NOTES_BREAKDOWN">SCENT NOTES (Fragrance layers)</option>
|
||||||
|
<option value="QUOTE_CALLOUT">QUOTE CALLOUT (Highlighted quote)</option>
|
||||||
|
<option value="PRODUCT_CAROUSEL">PRODUCT CAROUSEL (Featured products)</option>
|
||||||
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
|
<option value="ACCORDION">ACCORDION (FAQ Style)</option>
|
||||||
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
|
<option value="CONTACT_INFO">CONTACT INFO (Location/Hours)</option>
|
||||||
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
|
<option value="CONTACT_FORM">CONTACT FORM (Message inputs)</option>
|
||||||
@@ -1004,6 +1063,56 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{editingSection.type === 'HERO_ARTICLE' && (
|
||||||
|
<HeroArticleForm
|
||||||
|
editingSection={editingSection}
|
||||||
|
setEditingSection={setEditingSection}
|
||||||
|
editingLang={editingLang}
|
||||||
|
getLocalizedValue={getLocalizedValue}
|
||||||
|
updateLocalizedValue={updateLocalizedValue}
|
||||||
|
setUploadTarget={setUploadTarget}
|
||||||
|
setIsMediaModalOpen={setIsMediaModalOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type === 'RICH_TEXT' && (
|
||||||
|
<RichTextForm
|
||||||
|
editingSection={editingSection}
|
||||||
|
editingLang={editingLang}
|
||||||
|
getLocalizedValue={getLocalizedValue}
|
||||||
|
updateLocalizedValue={updateLocalizedValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type === 'SCENT_NOTES_BREAKDOWN' && (
|
||||||
|
<ScentNotesBreakdownForm
|
||||||
|
editingSection={editingSection}
|
||||||
|
setEditingSection={setEditingSection}
|
||||||
|
editingLang={editingLang}
|
||||||
|
getLocalizedValue={getLocalizedValue}
|
||||||
|
updateLocalizedValue={updateLocalizedValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type === 'QUOTE_CALLOUT' && (
|
||||||
|
<QuoteCalloutForm
|
||||||
|
editingSection={editingSection}
|
||||||
|
editingLang={editingLang}
|
||||||
|
getLocalizedValue={getLocalizedValue}
|
||||||
|
updateLocalizedValue={updateLocalizedValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingSection.type === 'PRODUCT_CAROUSEL' && (
|
||||||
|
<ProductCarouselForm
|
||||||
|
editingSection={editingSection}
|
||||||
|
setEditingSection={setEditingSection}
|
||||||
|
editingLang={editingLang}
|
||||||
|
getLocalizedValue={getLocalizedValue}
|
||||||
|
updateLocalizedValue={updateLocalizedValue}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'ACCORDION' && (
|
{editingSection.type === 'ACCORDION' && (
|
||||||
<AccordionForm
|
<AccordionForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Elysia, t } from "elysia";
|
import { Elysia, t } from "elysia";
|
||||||
import prisma from "../lib/prisma";
|
import prisma from "../lib/prisma";
|
||||||
import {
|
import {
|
||||||
|
LocalizedStringSchema,
|
||||||
HeroSectionPayloadSchema,
|
HeroSectionPayloadSchema,
|
||||||
CategoriesSectionPayloadSchema,
|
CategoriesSectionPayloadSchema,
|
||||||
ProductsSectionPayloadSchema,
|
ProductsSectionPayloadSchema,
|
||||||
@@ -21,6 +22,122 @@ import {
|
|||||||
} from "@homaroma/types";
|
} from "@homaroma/types";
|
||||||
import { adminOnly } from "../middleware/auth";
|
import { adminOnly } from "../middleware/auth";
|
||||||
|
|
||||||
|
function isLocalizedStringValue(value: unknown): value is string | Record<string, string> {
|
||||||
|
if (typeof value === "string") return true;
|
||||||
|
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
||||||
|
return Object.values(value).every((entry) => typeof entry === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLocalizedStringList(value: unknown): boolean {
|
||||||
|
return Array.isArray(value) && value.every((entry) => isLocalizedStringValue(entry));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidString(value: unknown): boolean {
|
||||||
|
return typeof value === "string" && value.trim().length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidNumber(value: unknown): boolean {
|
||||||
|
return typeof value === "number" && Number.isFinite(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasValidBoolean(value: unknown): boolean {
|
||||||
|
return typeof value === "boolean";
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateHeroArticlePayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (!isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
const localizedFields = ["subtitle", "author", "category"] as const;
|
||||||
|
for (const field of localizedFields) {
|
||||||
|
if (payload[field] !== undefined && !isLocalizedStringValue(payload[field])) {
|
||||||
|
return `${field}: Must be a localized string`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const stringFields = ["publishDate", "date", "imageUrl", "backgroundImageUrl", "overlayColor"] as const;
|
||||||
|
for (const field of stringFields) {
|
||||||
|
if (payload[field] !== undefined && !hasValidString(payload[field])) {
|
||||||
|
return `${field}: Must be a string`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRichTextPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
if (!isLocalizedStringValue(payload.content)) return "content: Must be a localized string";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateScentNotesBreakdownPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
if (payload.description !== undefined && !isLocalizedStringValue(payload.description)) return "description: Must be a localized string";
|
||||||
|
const listFields = ["topNotes", "heartNotes", "middleNotes", "baseNotes"] as const;
|
||||||
|
for (const field of listFields) {
|
||||||
|
const value = payload[field];
|
||||||
|
if (value !== undefined && !(isLocalizedStringValue(value) || isLocalizedStringList(value))) {
|
||||||
|
return `${field}: Must be a localized string or localized string list`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateQuoteCalloutPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (!isLocalizedStringValue(payload.quote)) return "quote: Must be a localized string";
|
||||||
|
if (payload.author !== undefined && !isLocalizedStringValue(payload.author)) return "author: Must be a localized string";
|
||||||
|
if (payload.role !== undefined && !isLocalizedStringValue(payload.role)) return "role: Must be a localized string";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateProductCarouselPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
if (payload.description !== undefined && !isLocalizedStringValue(payload.description)) return "description: Must be a localized string";
|
||||||
|
if (payload.collectionId !== undefined && !hasValidString(payload.collectionId)) return "collectionId: Must be a string";
|
||||||
|
if (payload.limit !== undefined && (!hasValidNumber(payload.limit) || payload.limit <= 0)) return "limit: Must be a positive number";
|
||||||
|
if (payload.showPrice !== undefined && !hasValidBoolean(payload.showPrice)) return "showPrice: Must be a boolean";
|
||||||
|
if (payload.showAddToCart !== undefined && !hasValidBoolean(payload.showAddToCart)) return "showAddToCart: Must be a boolean";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAccordionPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
if (!Array.isArray(payload.categories) || payload.categories.length === 0) return "categories: Must be a non-empty array";
|
||||||
|
for (const [catIndex, category] of payload.categories.entries()) {
|
||||||
|
if (!category || typeof category !== "object") return `categories.${catIndex}: Must be an object`;
|
||||||
|
if (!isLocalizedStringValue(category.name)) return `categories.${catIndex}.name: Must be a localized string`;
|
||||||
|
if (category.icon !== undefined && !hasValidString(category.icon)) return `categories.${catIndex}.icon: Must be a string`;
|
||||||
|
if (!Array.isArray(category.questions) || category.questions.length === 0) {
|
||||||
|
return `categories.${catIndex}.questions: Must be a non-empty array`;
|
||||||
|
}
|
||||||
|
for (const [qIndex, question] of category.questions.entries()) {
|
||||||
|
if (!question || typeof question !== "object") return `categories.${catIndex}.questions.${qIndex}: Must be an object`;
|
||||||
|
if (!isLocalizedStringValue(question.q)) return `categories.${catIndex}.questions.${qIndex}.q: Must be a localized string`;
|
||||||
|
if (!isLocalizedStringValue(question.a)) return `categories.${catIndex}.questions.${qIndex}.a: Must be a localized string`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContactInfoPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
for (const field of ["address", "email", "line", "hours"] as const) {
|
||||||
|
if (!isLocalizedStringValue(payload[field])) return `${field}: Must be a localized string`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateContactFormPayload(payload: any): string | null {
|
||||||
|
if (!payload || typeof payload !== "object") return "Payload must be a JSON object";
|
||||||
|
if (payload.title !== undefined && !isLocalizedStringValue(payload.title)) return "title: Must be a localized string";
|
||||||
|
if (payload.subtitle !== undefined && !isLocalizedStringValue(payload.subtitle)) return "subtitle: Must be a localized string";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const RESERVED_SLUGS = [
|
const RESERVED_SLUGS = [
|
||||||
"shop", "category", "products", "cart", "checkout", "account", "search",
|
"shop", "category", "products", "cart", "checkout", "account", "search",
|
||||||
"login", "admin", "wishlist", "track", "coupons", "scent-guide",
|
"login", "admin", "wishlist", "track", "coupons", "scent-guide",
|
||||||
@@ -28,6 +145,19 @@ const RESERVED_SLUGS = [
|
|||||||
"settings", "menus", "auth", "api"
|
"settings", "menus", "auth", "api"
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** Resolve category name — mirrors the same helper in categories.ts */
|
||||||
|
function resolveCatName(cat: any, lang: string): any {
|
||||||
|
if (lang === 'all') {
|
||||||
|
const nameObj: any = {};
|
||||||
|
cat.translations?.forEach((tr: any) => { nameObj[tr.langCode] = tr.name; });
|
||||||
|
return nameObj;
|
||||||
|
}
|
||||||
|
const trans = cat.translations?.find((t: any) => t.langCode === lang)
|
||||||
|
|| cat.translations?.find((t: any) => t.langCode === 'th')
|
||||||
|
|| cat.translations?.[0];
|
||||||
|
return trans?.name ?? cat.name;
|
||||||
|
}
|
||||||
|
|
||||||
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||||
.get("/", async () => {
|
.get("/", async () => {
|
||||||
return await prisma.page.findMany({
|
return await prisma.page.findMany({
|
||||||
@@ -125,6 +255,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
categories = await prisma.category.findMany({
|
categories = await prisma.category.findMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
include: {
|
include: {
|
||||||
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { products: true }
|
select: { products: true }
|
||||||
}
|
}
|
||||||
@@ -134,13 +265,18 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
|||||||
} else {
|
} else {
|
||||||
categories = await prisma.category.findMany({
|
categories = await prisma.category.findMany({
|
||||||
include: {
|
include: {
|
||||||
|
translations: true,
|
||||||
_count: {
|
_count: {
|
||||||
select: { products: true }
|
select: { products: true }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
populatedPayload.categories = categories;
|
// Resolve name for each category using the lang param (supports 'all')
|
||||||
|
populatedPayload.categories = categories.map((cat: any) => ({
|
||||||
|
...cat,
|
||||||
|
name: resolveCatName(cat, lang),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -452,43 +588,43 @@ function validateSectionPayload(type: string, payload: any): string | null {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'HERO_ARTICLE': {
|
case 'HERO_ARTICLE': {
|
||||||
const res = HeroArticlePayloadSchema.safeParse(payload);
|
const error = validateHeroArticlePayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'RICH_TEXT': {
|
case 'RICH_TEXT': {
|
||||||
const res = RichTextPayloadSchema.safeParse(payload);
|
const error = validateRichTextPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'SCENT_NOTES_BREAKDOWN': {
|
case 'SCENT_NOTES_BREAKDOWN': {
|
||||||
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
|
const error = validateScentNotesBreakdownPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'QUOTE_CALLOUT': {
|
case 'QUOTE_CALLOUT': {
|
||||||
const res = QuoteCalloutPayloadSchema.safeParse(payload);
|
const error = validateQuoteCalloutPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'PRODUCT_CAROUSEL': {
|
case 'PRODUCT_CAROUSEL': {
|
||||||
const res = ProductCarouselPayloadSchema.safeParse(payload);
|
const error = validateProductCarouselPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'ACCORDION': {
|
case 'ACCORDION': {
|
||||||
const res = AccordionSectionPayloadSchema.safeParse(payload);
|
const error = validateAccordionPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'CONTACT_INFO': {
|
case 'CONTACT_INFO': {
|
||||||
const res = ContactInfoSectionPayloadSchema.safeParse(payload);
|
const error = validateContactInfoPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case 'CONTACT_FORM': {
|
case 'CONTACT_FORM': {
|
||||||
const res = ContactFormSectionPayloadSchema.safeParse(payload);
|
const error = validateContactFormPayload(payload);
|
||||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
if (error) return error;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,15 +1,35 @@
|
|||||||
# mcp-server
|
# HomAroma MCP Server
|
||||||
|
|
||||||
To install dependencies:
|
MCP server สำหรับเชื่อม AI tools เข้ากับ backend ของ HomAroma ผ่าน stdio transport
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun install
|
bun install
|
||||||
|
bun run --cwd apps/mcp-server dev
|
||||||
```
|
```
|
||||||
|
|
||||||
To run:
|
หรือรันตรงจาก package:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
bun run index.ts
|
cd apps/mcp-server
|
||||||
|
bun run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.13. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
## Environment
|
||||||
|
|
||||||
|
รองรับตัวแปรต่อไปนี้:
|
||||||
|
|
||||||
|
- `MCP_BACKEND_URL` สำหรับกำหนด backend target ตรง ๆ
|
||||||
|
- `MCP_BACKEND_URL_LOCAL` ค่าเริ่มต้นสำหรับ local
|
||||||
|
- `MCP_BACKEND_URL_PRODUCTION` ค่าเริ่มต้นสำหรับ production
|
||||||
|
- `MCP_ADMIN_USERNAME`
|
||||||
|
- `MCP_ADMIN_PASSWORD`
|
||||||
|
|
||||||
|
ถ้าไม่ตั้งค่า `MCP_BACKEND_URL` server จะเริ่มที่ local backend และสลับเป็น production ได้ด้วย tool `set_target_environment`
|
||||||
|
|
||||||
|
## Scripts
|
||||||
|
|
||||||
|
- `bun run dev`
|
||||||
|
- `bun run start`
|
||||||
|
- `bun run check`
|
||||||
+224
-7
@@ -40,9 +40,196 @@ function findNodeAndParent(tree: any[], targetId: string, parent: any = null, li
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let BACKEND_URL = "http://localhost:3051/api";
|
const localizedStringSchema = {
|
||||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
|
oneOf: [
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123"; // เปลี่ยนรหัสผ่านตรงนี้ให้ตรงกับ Production ด้วยนะครับ
|
{ type: "string" },
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
additionalProperties: { type: "string" }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizedStringListSchema = {
|
||||||
|
type: "array",
|
||||||
|
items: localizedStringSchema
|
||||||
|
};
|
||||||
|
|
||||||
|
const SECTION_TOOL_CONFIGS = [
|
||||||
|
{
|
||||||
|
name: "update_hero_article",
|
||||||
|
sectionType: "HERO_ARTICLE",
|
||||||
|
description: "Update a HERO_ARTICLE section with structured editorial fields.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized article title" },
|
||||||
|
subtitle: { ...localizedStringSchema, description: "Localized subtitle" },
|
||||||
|
author: { ...localizedStringSchema, description: "Localized author name" },
|
||||||
|
category: { ...localizedStringSchema, description: "Localized category or tag" },
|
||||||
|
publishDate: { type: "string", description: "Publish date or display date" },
|
||||||
|
date: { type: "string", description: "Alternative date field accepted by backend" },
|
||||||
|
imageUrl: { type: "string", description: "Main image URL" },
|
||||||
|
backgroundImageUrl: { type: "string", description: "Background image URL" },
|
||||||
|
overlayColor: { type: "string", description: "Overlay color in hex or rgba" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_rich_text",
|
||||||
|
sectionType: "RICH_TEXT",
|
||||||
|
description: "Update a RICH_TEXT section with localized title and content.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized title displayed above the content" },
|
||||||
|
content: { ...localizedStringSchema, description: "Localized HTML or plain text content" }
|
||||||
|
},
|
||||||
|
required: ["sectionId", "content"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_scent_notes_breakdown",
|
||||||
|
sectionType: "SCENT_NOTES_BREAKDOWN",
|
||||||
|
description: "Update a SCENT_NOTES_BREAKDOWN section with localized title, description, and note groups.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized title" },
|
||||||
|
description: { ...localizedStringSchema, description: "Localized supporting description" },
|
||||||
|
topNotes: { type: "array", items: localizedStringSchema, description: "Top note values" },
|
||||||
|
heartNotes: { type: "array", items: localizedStringSchema, description: "Heart note values" },
|
||||||
|
middleNotes: { type: "array", items: localizedStringSchema, description: "Middle note values" },
|
||||||
|
baseNotes: { type: "array", items: localizedStringSchema, description: "Base note values" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_quote_callout",
|
||||||
|
sectionType: "QUOTE_CALLOUT",
|
||||||
|
description: "Update a QUOTE_CALLOUT section with a localized quote, author, and role.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
quote: { ...localizedStringSchema, description: "Localized quote text" },
|
||||||
|
author: { ...localizedStringSchema, description: "Localized author name" },
|
||||||
|
role: { ...localizedStringSchema, description: "Localized author role or title" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_product_carousel",
|
||||||
|
sectionType: "PRODUCT_CAROUSEL",
|
||||||
|
description: "Update a PRODUCT_CAROUSEL section with localized copy and carousel options.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized title" },
|
||||||
|
description: { ...localizedStringSchema, description: "Localized supporting description" },
|
||||||
|
collectionId: { type: "string", description: "Optional collection identifier" },
|
||||||
|
limit: { type: "number", description: "Maximum number of products to show" },
|
||||||
|
showPrice: { type: "boolean", description: "Show product price" },
|
||||||
|
showAddToCart: { type: "boolean", description: "Show add to cart button" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_accordion",
|
||||||
|
sectionType: "ACCORDION",
|
||||||
|
description: "Update an ACCORDION section with categories, questions, and localized answers.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized section title" },
|
||||||
|
categories: {
|
||||||
|
type: "array",
|
||||||
|
description: "Accordion categories",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
name: { ...localizedStringSchema, description: "Localized category name" },
|
||||||
|
icon: { type: "string", description: "Optional icon or emoji" },
|
||||||
|
questions: {
|
||||||
|
type: "array",
|
||||||
|
description: "Questions under the category",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
q: { ...localizedStringSchema, description: "Localized question" },
|
||||||
|
a: { ...localizedStringSchema, description: "Localized answer" }
|
||||||
|
},
|
||||||
|
required: ["q", "a"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["name", "questions"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
required: ["sectionId", "categories"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_contact_info",
|
||||||
|
sectionType: "CONTACT_INFO",
|
||||||
|
description: "Update a CONTACT_INFO section with localized address, email, line, and hours.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized section title" },
|
||||||
|
address: { ...localizedStringSchema, description: "Localized address" },
|
||||||
|
email: { ...localizedStringSchema, description: "Localized email" },
|
||||||
|
line: { ...localizedStringSchema, description: "Localized Line ID or contact" },
|
||||||
|
hours: { ...localizedStringSchema, description: "Localized business hours" }
|
||||||
|
},
|
||||||
|
required: ["sectionId", "address", "email", "line", "hours"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "update_contact_form",
|
||||||
|
sectionType: "CONTACT_FORM",
|
||||||
|
description: "Update a CONTACT_FORM section with localized title and subtitle.",
|
||||||
|
properties: {
|
||||||
|
sectionId: { type: "number", description: "The numeric ID of the section to update" },
|
||||||
|
title: { ...localizedStringSchema, description: "Localized form title" },
|
||||||
|
subtitle: { ...localizedStringSchema, description: "Localized form subtitle" }
|
||||||
|
},
|
||||||
|
required: ["sectionId"]
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const SECTION_TOOL_CONFIG_BY_NAME = new Map(
|
||||||
|
SECTION_TOOL_CONFIGS.map((config) => [config.name, config] as const)
|
||||||
|
);
|
||||||
|
|
||||||
|
function buildSectionPayload(params: any, fields: readonly string[]) {
|
||||||
|
const payload: Record<string, any> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
if (params[field] !== undefined) {
|
||||||
|
payload[field] = params[field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSectionUpdateResult(toolName: string, sectionType: string, updatedSection: any, payload: Record<string, any>) {
|
||||||
|
return {
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: JSON.stringify(
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
tool: toolName,
|
||||||
|
sectionType,
|
||||||
|
sectionId: updatedSection?.id,
|
||||||
|
updatedFields: Object.keys(payload),
|
||||||
|
section: updatedSection
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCAL_BACKEND_URL = process.env.MCP_BACKEND_URL_LOCAL || "http://localhost:3051/api";
|
||||||
|
const PRODUCTION_BACKEND_URL = process.env.MCP_BACKEND_URL_PRODUCTION || "https://api.homaroma.shop/api";
|
||||||
|
|
||||||
|
let BACKEND_URL = process.env.MCP_BACKEND_URL || LOCAL_BACKEND_URL;
|
||||||
|
const ADMIN_USERNAME = process.env.MCP_ADMIN_USERNAME || process.env.ADMIN_USERNAME || "admin";
|
||||||
|
const ADMIN_PASSWORD = process.env.MCP_ADMIN_PASSWORD || process.env.ADMIN_PASSWORD || "homaroma123";
|
||||||
|
|
||||||
let authToken = "";
|
let authToken = "";
|
||||||
|
|
||||||
@@ -442,6 +629,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|||||||
required: ["sectionId"]
|
required: ["sectionId"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
...SECTION_TOOL_CONFIGS.map((config) => ({
|
||||||
|
name: config.name,
|
||||||
|
description: config.description,
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: config.properties,
|
||||||
|
required: config.required
|
||||||
|
}
|
||||||
|
})),
|
||||||
{
|
{
|
||||||
name: "delete_page_section",
|
name: "delete_page_section",
|
||||||
description: "Delete a block section from a dynamic page layout",
|
description: "Delete a block section from a dynamic page layout",
|
||||||
@@ -569,9 +765,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
case "set_target_environment": {
|
case "set_target_environment": {
|
||||||
const env = params.env;
|
const env = params.env;
|
||||||
if (env === "production") {
|
if (env === "production") {
|
||||||
BACKEND_URL = "https://api.homaroma.shop/api";
|
BACKEND_URL = PRODUCTION_BACKEND_URL;
|
||||||
} else {
|
} else {
|
||||||
BACKEND_URL = "http://localhost:3051/api";
|
BACKEND_URL = LOCAL_BACKEND_URL;
|
||||||
}
|
}
|
||||||
api.defaults.baseURL = BACKEND_URL;
|
api.defaults.baseURL = BACKEND_URL;
|
||||||
authToken = ""; // reset token so it logs in again on next request
|
authToken = ""; // reset token so it logs in again on next request
|
||||||
@@ -807,7 +1003,28 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|||||||
if (params.payload) payload.payload = params.payload;
|
if (params.payload) payload.payload = params.payload;
|
||||||
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
if (params.isActive !== undefined) payload.isActive = params.isActive;
|
||||||
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
const res = await api.put(`/pages/sections/${params.sectionId}`, payload);
|
||||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
return buildSectionUpdateResult("update_page_section", res.data?.type ?? params.type ?? "UNKNOWN", res.data, payload);
|
||||||
|
}
|
||||||
|
case "update_hero_article":
|
||||||
|
case "update_rich_text":
|
||||||
|
case "update_scent_notes_breakdown":
|
||||||
|
case "update_quote_callout":
|
||||||
|
case "update_product_carousel":
|
||||||
|
case "update_accordion":
|
||||||
|
case "update_contact_info":
|
||||||
|
case "update_contact_form": {
|
||||||
|
const config = SECTION_TOOL_CONFIG_BY_NAME.get(request.params.name);
|
||||||
|
if (!config) {
|
||||||
|
throw new Error(`Unknown tool config for: ${request.params.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSectionPayload(params, Object.keys(config.properties).filter((key) => key !== "sectionId"));
|
||||||
|
const res = await api.put(`/pages/sections/${params.sectionId}`, {
|
||||||
|
type: config.sectionType,
|
||||||
|
...payload
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildSectionUpdateResult(request.params.name, config.sectionType, res.data, payload);
|
||||||
}
|
}
|
||||||
case "delete_page_section": {
|
case "delete_page_section": {
|
||||||
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
||||||
@@ -1043,4 +1260,4 @@ async function main() {
|
|||||||
console.error("HomAroma MCP Server running on stdio");
|
console.error("HomAroma MCP Server running on stdio");
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(console.error);
|
main().catch(console.error);
|
||||||
@@ -2,6 +2,11 @@
|
|||||||
"name": "mcp-server",
|
"name": "mcp-server",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun --watch index.ts",
|
||||||
|
"start": "bun index.ts",
|
||||||
|
"check": "bunx tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest"
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
cd /home/kitisak/Projects/homaroma/apps/mcp-server
|
set -euo pipefail
|
||||||
/home/kitisak/.bun/bin/bun run index.ts
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
bun run index.ts
|
||||||
@@ -49,7 +49,13 @@ export default function DynamicPageRenderer({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'PRODUCTS':
|
case 'PRODUCTS':
|
||||||
return <DynamicProducts key={section.id} payload={section.payload} />;
|
return (
|
||||||
|
<DynamicProducts
|
||||||
|
key={section.id}
|
||||||
|
payload={section.payload}
|
||||||
|
categories={categories}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case 'BANNER':
|
case 'BANNER':
|
||||||
return <DynamicBanner key={section.id} payload={section.payload} />;
|
return <DynamicBanner key={section.id} payload={section.payload} />;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Pagination } from 'swiper/modules';
|
import { Autoplay, Pagination } from 'swiper/modules';
|
||||||
import ProductCard from './ProductCard';
|
import ProductCard from './ProductCard';
|
||||||
import type { Product, ProductsSectionPayload } from '@homaroma/types';
|
import type { Category, Product, ProductsSectionPayload } from '@homaroma/types';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getLocalizedField } from '../utils/localization';
|
import { getLocalizedField } from '../utils/localization';
|
||||||
|
|
||||||
@@ -12,14 +12,16 @@ import 'swiper/css/pagination';
|
|||||||
|
|
||||||
interface DynamicProductsProps {
|
interface DynamicProductsProps {
|
||||||
payload: ProductsSectionPayload & { products?: Product[] };
|
payload: ProductsSectionPayload & { products?: Product[] };
|
||||||
|
categories?: Category[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
export default function DynamicProducts({ payload, categories = [] }: DynamicProductsProps) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n, t } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const { products = [], layout = 'grid', enableQuickShop = false } = payload;
|
const { products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||||
const title = getLocalizedField(payload.title, lang);
|
const title = getLocalizedField(payload.title, lang);
|
||||||
const subtitle = getLocalizedField(payload.subtitle, lang);
|
const subtitle = getLocalizedField(payload.subtitle, lang);
|
||||||
|
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="bg-pop-bg py-24 px-6 relative">
|
<section className="bg-pop-bg py-24 px-6 relative">
|
||||||
@@ -37,7 +39,8 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
|||||||
to="/shop"
|
to="/shop"
|
||||||
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
||||||
>
|
>
|
||||||
View All Collection →
|
{t('common.viewAllCollection')}
|
||||||
|
→
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -61,7 +64,11 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
|||||||
>
|
>
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<SwiperSlide key={p.id} className="h-auto">
|
<SwiperSlide key={p.id} className="h-auto">
|
||||||
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
<ProductCard
|
||||||
|
product={p}
|
||||||
|
enableQuickShop={enableQuickShop}
|
||||||
|
categoryNameOverride={getLocalizedField(categoryLookup.get(p.categoryId)?.name, lang)}
|
||||||
|
/>
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
@@ -70,7 +77,11 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
{products.map((p) => (
|
{products.map((p) => (
|
||||||
<div key={p.id}>
|
<div key={p.id}>
|
||||||
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
<ProductCard
|
||||||
|
product={p}
|
||||||
|
enableQuickShop={enableQuickShop}
|
||||||
|
categoryNameOverride={getLocalizedField(categoryLookup.get(p.categoryId)?.name, lang)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,9 +11,21 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
|
|||||||
const p = payload || {};
|
const p = payload || {};
|
||||||
const title = getLocalizedField(p.title, lang);
|
const title = getLocalizedField(p.title, lang);
|
||||||
const description = getLocalizedField(p.description, lang);
|
const description = getLocalizedField(p.description, lang);
|
||||||
const topNotes = getLocalizedField(p.topNotes, lang);
|
const notes = (value: any) => Array.isArray(value)
|
||||||
const heartNotes = getLocalizedField(p.heartNotes, lang);
|
? value.map((item) => getLocalizedField(item, lang)).filter(Boolean).join(', ')
|
||||||
const baseNotes = getLocalizedField(p.baseNotes, lang);
|
: getLocalizedField(value, lang);
|
||||||
|
const labels = {
|
||||||
|
th: { top: 'Top Notes', heart: 'Heart Notes', base: 'Base Notes' },
|
||||||
|
en: { top: 'Top Notes', heart: 'Heart Notes', base: 'Base Notes' },
|
||||||
|
ja: { top: 'トップノート', heart: 'ハートノート', base: 'ベースノート' },
|
||||||
|
zh: { top: '前调', heart: '中调', base: '后调' },
|
||||||
|
vi: { top: 'Hương đầu', heart: 'Hương giữa', base: 'Hương cuối' },
|
||||||
|
ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' }
|
||||||
|
} as const;
|
||||||
|
const labelSet = labels[lang as keyof typeof labels] ?? labels.en;
|
||||||
|
const topNotes = notes(p.topNotes);
|
||||||
|
const heartNotes = notes(p.heartNotes ?? p.middleNotes);
|
||||||
|
const baseNotes = notes(p.baseNotes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 px-6 bg-spa-light">
|
<section className="py-20 px-6 bg-spa-light">
|
||||||
@@ -29,21 +41,21 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Top Notes</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.top}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-spa-stone font-light leading-relaxed">
|
||||||
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Heart Notes</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.heart}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-spa-stone font-light leading-relaxed">
|
||||||
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
<div className="p-8 bg-white rounded-2xl shadow-sm border border-spa-stone/10">
|
||||||
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">Base Notes</h3>
|
<h3 className="text-xl font-bold uppercase tracking-widest text-spa-forest mb-4">{labelSet.base}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-spa-stone font-light leading-relaxed">
|
||||||
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -16,9 +16,10 @@ function resolveStr(v: TranslatableString | null | undefined, lang: string): str
|
|||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
product: Product;
|
product: Product;
|
||||||
enableQuickShop?: boolean;
|
enableQuickShop?: boolean;
|
||||||
|
categoryNameOverride?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product, enableQuickShop = false }: ProductCardProps) {
|
export default function ProductCard({ product, enableQuickShop = false, categoryNameOverride }: ProductCardProps) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
const { addItem, removeItem, isInWishlist } = useWishlistStore();
|
||||||
const { addToCart } = useCartStore();
|
const { addToCart } = useCartStore();
|
||||||
@@ -26,6 +27,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
|||||||
const isWished = isInWishlist(product.id);
|
const isWished = isInWishlist(product.id);
|
||||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||||
const displayName = resolveStr(product.name, i18n.language) || product.scent.name;
|
const displayName = resolveStr(product.name, i18n.language) || product.scent.name;
|
||||||
|
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
||||||
|
|
||||||
const handleWishlistClick = (e: React.MouseEvent) => {
|
const handleWishlistClick = (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -49,7 +51,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
|||||||
{/* Image Area */}
|
{/* Image Area */}
|
||||||
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
||||||
<img
|
<img
|
||||||
src={product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')}
|
src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
alt={product.scent.name}
|
alt={product.scent.name}
|
||||||
onError={(e) => {
|
onError={(e) => {
|
||||||
@@ -58,7 +60,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
|||||||
/>
|
/>
|
||||||
{/* Category badge */}
|
{/* Category badge */}
|
||||||
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm">
|
<div className="absolute top-3 left-3 bg-white/90 backdrop-blur-sm px-2.5 py-1 rounded-md text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm">
|
||||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
{displayCategoryName}
|
||||||
</div>
|
</div>
|
||||||
{/* Wishlist Button */}
|
{/* Wishlist Button */}
|
||||||
<button
|
<button
|
||||||
@@ -79,7 +81,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
|||||||
{displayName}
|
{displayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
|
<p className="text-[10px] md:text-xs text-spa-stone/60 mt-1 uppercase tracking-widest line-clamp-1 font-semibold">
|
||||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
{displayCategoryName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,13 +138,13 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
|||||||
price: selectedVariant.price || 0,
|
price: selectedVariant.price || 0,
|
||||||
productName: displayName,
|
productName: displayName,
|
||||||
variantSize: selectedVariant.size,
|
variantSize: selectedVariant.size,
|
||||||
productImage: product.images?.[0]?.url || getCategoryImage(typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.en || '')
|
productImage: product.images?.[0]?.url || getCategoryImage(displayCategoryName)
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full py-2.5 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-xl font-bold text-xs uppercase tracking-widest transition-all shadow-sm hover:shadow flex items-center justify-center gap-1.5 cursor-pointer"
|
className="w-full py-2.5 bg-spa-forest text-white hover:bg-spa-forest/90 rounded-xl font-bold text-xs uppercase tracking-widest transition-all shadow-sm hover:shadow flex items-center justify-center gap-1.5 cursor-pointer"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-3.5 h-3.5" />
|
<ShoppingBag className="w-3.5 h-3.5" />
|
||||||
Add to Cart
|
{t('cart.addToCart')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import type { AccordionSectionPayload } from '@homaroma/types';
|
import type { AccordionSectionPayload } from '@homaroma/types';
|
||||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getLocalizedField } from '../../utils/localization';
|
||||||
|
|
||||||
interface AccordionSectionProps {
|
interface AccordionSectionProps {
|
||||||
payload: AccordionSectionPayload;
|
payload: AccordionSectionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AccordionSection({ payload }: AccordionSectionProps) {
|
export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
const [openIndex, setOpenIndex] = useState<string | null>(null);
|
const [openIndex, setOpenIndex] = useState<string | null>(null);
|
||||||
|
const title = getLocalizedField((payload as any)?.title, lang);
|
||||||
|
|
||||||
const toggleAccordion = (index: string) => {
|
const toggleAccordion = (index: string) => {
|
||||||
setOpenIndex(openIndex === index ? null : index);
|
setOpenIndex(openIndex === index ? null : index);
|
||||||
@@ -16,11 +21,16 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="w-full py-16 px-4 md:px-8 bg-spa-sand/20">
|
<section className="w-full py-16 px-4 md:px-8 bg-spa-sand/20">
|
||||||
<div className="max-w-4xl mx-auto">
|
<div className="max-w-4xl mx-auto">
|
||||||
|
{title && (
|
||||||
|
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-10 text-center">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
{payload.categories.map((category, catIndex) => (
|
{payload.categories.map((category, catIndex) => (
|
||||||
<div key={catIndex} className="mb-12 last:mb-0">
|
<div key={catIndex} className="mb-12 last:mb-0">
|
||||||
<h3 className="text-2xl font-serif text-spa-charcoal mb-6 flex items-center gap-3">
|
<h3 className="text-2xl font-serif text-spa-charcoal mb-6 flex items-center gap-3">
|
||||||
{category.icon && <span className="text-spa-sage">{category.icon}</span>}
|
{category.icon && <span className="text-spa-sage">{category.icon}</span>}
|
||||||
{category.name}
|
{getLocalizedField(category.name, lang)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
{category.questions.map((item, qIndex) => {
|
{category.questions.map((item, qIndex) => {
|
||||||
@@ -37,7 +47,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
onClick={() => toggleAccordion(indexKey)}
|
onClick={() => toggleAccordion(indexKey)}
|
||||||
>
|
>
|
||||||
<span className="text-lg font-medium text-spa-charcoal pr-4">
|
<span className="text-lg font-medium text-spa-charcoal pr-4">
|
||||||
{item.q}
|
{getLocalizedField(item.q, lang)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-spa-sage flex-shrink-0">
|
<span className="text-spa-sage flex-shrink-0">
|
||||||
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
@@ -49,7 +59,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-spa-charcoal/80 leading-relaxed whitespace-pre-wrap">
|
<p className="text-spa-charcoal/80 leading-relaxed whitespace-pre-wrap">
|
||||||
{item.a}
|
{getLocalizedField(item.a, lang)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { ContactFormSectionPayload } from '@homaroma/types';
|
import type { ContactFormSectionPayload } from '@homaroma/types';
|
||||||
import { Send } from 'lucide-react';
|
import { Send } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getLocalizedField } from '../../utils/localization';
|
||||||
|
|
||||||
interface ContactFormSectionProps {
|
interface ContactFormSectionProps {
|
||||||
payload: ContactFormSectionPayload;
|
payload: ContactFormSectionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactFormSection({ payload }: ContactFormSectionProps) {
|
export default function ContactFormSection({ payload }: ContactFormSectionProps) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -14,6 +18,84 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
});
|
});
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [isSuccess, setIsSuccess] = useState(false);
|
const [isSuccess, setIsSuccess] = useState(false);
|
||||||
|
const p = payload || {};
|
||||||
|
const title = getLocalizedField(p.title, lang);
|
||||||
|
const subtitle = getLocalizedField(p.subtitle, lang);
|
||||||
|
const labels = {
|
||||||
|
th: {
|
||||||
|
title: 'ติดต่อเรา',
|
||||||
|
subtitle: 'ส่งข้อความถึงทีมงานของเรา เรายินดีให้บริการและพร้อมตอบทุกข้อสงสัย',
|
||||||
|
successTitle: 'ส่งข้อความสำเร็จ',
|
||||||
|
successBody: 'ขอบคุณที่ติดต่อเรา ทีมงานจะรีบตอบกลับโดยเร็วที่สุด',
|
||||||
|
name: 'ชื่อ-นามสกุล',
|
||||||
|
namePlaceholder: 'กรอกชื่อของคุณ',
|
||||||
|
email: 'อีเมล',
|
||||||
|
message: 'ข้อความ',
|
||||||
|
messagePlaceholder: 'พิมพ์ข้อความของคุณที่นี่...',
|
||||||
|
submit: 'ส่งข้อความ',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Contact Us',
|
||||||
|
subtitle: 'Send a message to our team and we will get back to you soon.',
|
||||||
|
successTitle: 'Message sent successfully',
|
||||||
|
successBody: 'Thanks for reaching out. Our team will reply as soon as possible.',
|
||||||
|
name: 'Full Name',
|
||||||
|
namePlaceholder: 'Enter your name',
|
||||||
|
email: 'Email',
|
||||||
|
message: 'Message',
|
||||||
|
messagePlaceholder: 'Type your message here...',
|
||||||
|
submit: 'Send Message',
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
title: 'お問い合わせ',
|
||||||
|
subtitle: 'チームへメッセージをお送りください。できるだけ早くご返信します。',
|
||||||
|
successTitle: '送信に成功しました',
|
||||||
|
successBody: 'お問い合わせありがとうございます。担当者ができるだけ早く返信します。',
|
||||||
|
name: '氏名',
|
||||||
|
namePlaceholder: 'お名前を入力してください',
|
||||||
|
email: 'メール',
|
||||||
|
message: 'メッセージ',
|
||||||
|
messagePlaceholder: 'ここにメッセージを入力してください...',
|
||||||
|
submit: '送信',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: '联系我们',
|
||||||
|
subtitle: '给我们的团队留言,我们会尽快回复您。',
|
||||||
|
successTitle: '发送成功',
|
||||||
|
successBody: '感谢您的联系,我们会尽快回复。',
|
||||||
|
name: '姓名',
|
||||||
|
namePlaceholder: '请输入您的姓名',
|
||||||
|
email: '邮箱',
|
||||||
|
message: '留言',
|
||||||
|
messagePlaceholder: '在这里输入您的留言...',
|
||||||
|
submit: '发送消息',
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
title: 'Liên hệ',
|
||||||
|
subtitle: 'Gửi tin nhắn cho đội ngũ của chúng tôi, chúng tôi sẽ phản hồi sớm nhất.',
|
||||||
|
successTitle: 'Gửi tin nhắn thành công',
|
||||||
|
successBody: 'Cảm ơn bạn đã liên hệ, đội ngũ của chúng tôi sẽ phản hồi sớm nhất có thể.',
|
||||||
|
name: 'Họ và tên',
|
||||||
|
namePlaceholder: 'Nhập tên của bạn',
|
||||||
|
email: 'Email',
|
||||||
|
message: 'Tin nhắn',
|
||||||
|
messagePlaceholder: 'Nhập tin nhắn của bạn tại đây...',
|
||||||
|
submit: 'Gửi tin nhắn',
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
title: '문의하기',
|
||||||
|
subtitle: '팀에 메시지를 보내주시면 최대한 빨리 답변드리겠습니다.',
|
||||||
|
successTitle: '메시지 전송 완료',
|
||||||
|
successBody: '문의해 주셔서 감사합니다. 가능한 빨리 답변드리겠습니다.',
|
||||||
|
name: '이름',
|
||||||
|
namePlaceholder: '이름을 입력하세요',
|
||||||
|
email: '이메일',
|
||||||
|
message: '메시지',
|
||||||
|
messagePlaceholder: '여기에 메시지를 입력하세요...',
|
||||||
|
submit: '메시지 보내기',
|
||||||
|
}
|
||||||
|
} as const;
|
||||||
|
const copy = labels[lang as keyof typeof labels] ?? labels.en;
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -40,10 +122,10 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-4">
|
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-4">
|
||||||
{payload.title || "ติดต่อเรา"}
|
{title || copy.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-spa-charcoal/70 max-w-xl mx-auto">
|
<p className="text-lg text-spa-charcoal/70 max-w-xl mx-auto">
|
||||||
{payload.subtitle || "ส่งข้อความถึงทีมงานของเรา เรายินดีให้บริการและพร้อมตอบทุกข้อสงสัย"}
|
{subtitle || copy.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -53,15 +135,15 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<Send size={32} />
|
<Send size={32} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-serif text-spa-charcoal mb-2">ส่งข้อความสำเร็จ</h3>
|
<h3 className="text-2xl font-serif text-spa-charcoal mb-2">{copy.successTitle}</h3>
|
||||||
<p className="text-spa-charcoal/70">ขอบคุณที่ติดต่อเรา ทีมงานจะรีบตอบกลับโดยเร็วที่สุด</p>
|
<p className="text-spa-charcoal/70">{copy.successBody}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="name" className="block text-sm font-medium text-spa-charcoal/80">
|
||||||
ชื่อ-นามสกุล
|
{copy.name}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -71,12 +153,12 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all"
|
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all"
|
||||||
placeholder="กรอกชื่อของคุณ"
|
placeholder={copy.namePlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="email" className="block text-sm font-medium text-spa-charcoal/80">
|
||||||
อีเมล
|
{copy.email}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
@@ -93,7 +175,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="message" className="block text-sm font-medium text-spa-charcoal/80">
|
||||||
ข้อความ
|
{copy.message}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="message"
|
id="message"
|
||||||
@@ -103,7 +185,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all resize-none"
|
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all resize-none"
|
||||||
placeholder="พิมพ์ข้อความของคุณที่นี่..."
|
placeholder={copy.messagePlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -117,7 +199,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send size={18} />
|
<Send size={18} />
|
||||||
ส่งข้อความ
|
{copy.submit}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,57 +1,67 @@
|
|||||||
|
|
||||||
import type { ContactInfoSectionPayload } from '@homaroma/types';
|
import type { ContactInfoSectionPayload } from '@homaroma/types';
|
||||||
import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react';
|
import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getLocalizedField } from '../../utils/localization';
|
||||||
|
|
||||||
interface ContactInfoSectionProps {
|
interface ContactInfoSectionProps {
|
||||||
payload: ContactInfoSectionPayload;
|
payload: ContactInfoSectionPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ContactInfoSection({ payload }: ContactInfoSectionProps) {
|
export default function ContactInfoSection({ payload }: ContactInfoSectionProps) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const p = payload || {};
|
||||||
|
const title = getLocalizedField((p as any).title, lang);
|
||||||
|
const labels = {
|
||||||
|
th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', hours: 'เวลาทำการ' },
|
||||||
|
en: { address: 'Address', email: 'Email', line: 'Line', hours: 'Business Hours' },
|
||||||
|
ja: { address: '住所', email: 'メール', line: 'LINE', hours: '営業時間' },
|
||||||
|
zh: { address: '地址', email: '邮箱', line: 'Line', hours: '营业时间' },
|
||||||
|
vi: { address: 'Địa chỉ', email: 'Email', line: 'Line', hours: 'Giờ làm việc' },
|
||||||
|
ko: { address: '주소', email: '이메일', line: 'Line', hours: '영업 시간' }
|
||||||
|
} as const;
|
||||||
|
const labelSet = labels[lang as keyof typeof labels] ?? labels.en;
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
[labelSet.address, getLocalizedField((p as any).address, lang), MapPin],
|
||||||
|
[labelSet.email, getLocalizedField((p as any).email, lang), Mail],
|
||||||
|
[labelSet.line, getLocalizedField((p as any).line, lang), MessageCircle],
|
||||||
|
[labelSet.hours, getLocalizedField((p as any).hours, lang), Clock],
|
||||||
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full py-16 px-4 md:px-8 bg-white">
|
<section className="w-full py-16 px-4 md:px-8 bg-white">
|
||||||
<div className="max-w-6xl mx-auto">
|
<div className="max-w-6xl mx-auto">
|
||||||
|
{title && (
|
||||||
|
<div className="text-center mb-10">
|
||||||
|
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8">
|
||||||
|
{items.map(([label, value, Icon]) => {
|
||||||
<div className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300">
|
if (!value) return null;
|
||||||
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
|
||||||
<MapPin size={24} />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-lg font-serif text-spa-charcoal mb-2">ที่อยู่</h4>
|
|
||||||
<p className="text-spa-charcoal/70 leading-relaxed whitespace-pre-wrap">
|
|
||||||
{payload.address}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300">
|
|
||||||
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
|
||||||
<Mail size={24} />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-lg font-serif text-spa-charcoal mb-2">อีเมล</h4>
|
|
||||||
<a href={`mailto:${payload.email}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors">
|
|
||||||
{payload.email}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300">
|
|
||||||
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
|
||||||
<MessageCircle size={24} />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-lg font-serif text-spa-charcoal mb-2">Line</h4>
|
|
||||||
<p className="text-spa-charcoal/70">
|
|
||||||
{payload.line}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300">
|
|
||||||
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
|
||||||
<Clock size={24} />
|
|
||||||
</div>
|
|
||||||
<h4 className="text-lg font-serif text-spa-charcoal mb-2">เวลาทำการ</h4>
|
|
||||||
<p className="text-spa-charcoal/70 whitespace-pre-wrap">
|
|
||||||
{payload.hours}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={label} className="flex flex-col items-center text-center p-6 bg-spa-sand/10 rounded-2xl border border-spa-sage/20 hover:border-spa-sage transition-colors duration-300">
|
||||||
|
<div className="w-12 h-12 bg-spa-sage/10 rounded-full flex items-center justify-center text-spa-sage mb-4">
|
||||||
|
<Icon size={24} />
|
||||||
|
</div>
|
||||||
|
<h4 className="text-lg font-serif text-spa-charcoal mb-2">{label}</h4>
|
||||||
|
{label === labelSet.email ? (
|
||||||
|
<a href={`mailto:${value}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors">
|
||||||
|
{value}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-spa-charcoal/70 leading-relaxed whitespace-pre-wrap">
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"home": "Home",
|
"home": "Home",
|
||||||
"shop": "Shop",
|
"shop": "Shop",
|
||||||
"viewAll": "View All →",
|
"viewAll": "View All →",
|
||||||
|
"viewAllCollection": "View All Collection →",
|
||||||
"backToShop": "Back to Shop"
|
"backToShop": "Back to Shop"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"home": "ホーム",
|
"home": "ホーム",
|
||||||
"shop": "ショップ",
|
"shop": "ショップ",
|
||||||
"viewAll": "すべて見る →",
|
"viewAll": "すべて見る →",
|
||||||
|
"viewAllCollection": "すべてのコレクションを見る →",
|
||||||
"backToShop": "ショップに戻る"
|
"backToShop": "ショップに戻る"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"home": "홈",
|
"home": "홈",
|
||||||
"shop": "쇼핑",
|
"shop": "쇼핑",
|
||||||
"viewAll": "모두 보기 →",
|
"viewAll": "모두 보기 →",
|
||||||
|
"viewAllCollection": "전체 컬렉션 보기 →",
|
||||||
"backToShop": "쇼핑으로 돌아가기"
|
"backToShop": "쇼핑으로 돌아가기"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@
|
|||||||
"home": "หน้าหลัก",
|
"home": "หน้าหลัก",
|
||||||
"shop": "ร้านค้า",
|
"shop": "ร้านค้า",
|
||||||
"viewAll": "ดูทั้งหมด →",
|
"viewAll": "ดูทั้งหมด →",
|
||||||
|
"viewAllCollection": "ดูสินค้าทั้งหมด →",
|
||||||
"backToShop": "กลับไปร้านค้า"
|
"backToShop": "กลับไปร้านค้า"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"home": "Trang chủ",
|
"home": "Trang chủ",
|
||||||
"shop": "Cửa hàng",
|
"shop": "Cửa hàng",
|
||||||
"viewAll": "Xem tất cả →",
|
"viewAll": "Xem tất cả →",
|
||||||
|
"viewAllCollection": "Xem tất cả bộ sưu tập →",
|
||||||
"backToShop": "Quay lại cửa hàng"
|
"backToShop": "Quay lại cửa hàng"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@
|
|||||||
"home": "首页",
|
"home": "首页",
|
||||||
"shop": "商店",
|
"shop": "商店",
|
||||||
"viewAll": "查看全部 →",
|
"viewAll": "查看全部 →",
|
||||||
|
"viewAllCollection": "查看全部系列 →",
|
||||||
"backToShop": "返回商店"
|
"backToShop": "返回商店"
|
||||||
},
|
},
|
||||||
"product": {
|
"product": {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { Metadata } from 'next'
|
|||||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
const reservedSlugs = new Set([
|
const reservedSlugs = new Set([
|
||||||
'about',
|
'about',
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult
|
|||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../../lib/localization'
|
import { resolveLocalized } from '../../../lib/localization'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
type CategoryProductsResponse = {
|
type CategoryProductsResponse = {
|
||||||
category?: Category
|
category?: Category
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
|||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Checkout | HomAroma',
|
title: 'Checkout | HomAroma',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
|||||||
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
function formatDiscount(type: string, value: number) {
|
function formatDiscount(type: string, value: number) {
|
||||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||||
|
|||||||
@@ -4,16 +4,18 @@ import Navbar from '../components/Navbar';
|
|||||||
import Footer from '../components/Footer';
|
import Footer from '../components/Footer';
|
||||||
import ClientProviders from '../components/ClientProviders';
|
import ClientProviders from '../components/ClientProviders';
|
||||||
import { API_BASE } from '../lib/api';
|
import { API_BASE } from '../lib/api';
|
||||||
|
import type { MegaMenuPayload } from '../components/navigation/menuTypes';
|
||||||
|
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'HomAroma',
|
title: 'HomAroma',
|
||||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Fetch categories for Navbar and Footer on the server
|
// Fetch categories for Navbar and Footer on the server (lang=all for client-side switching)
|
||||||
async function getCategories() {
|
async function getCategories() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate: 3600 } });
|
const res = await fetch(`${API_BASE}/categories?lang=all`, { next: { revalidate: 3600 } });
|
||||||
if (!res.ok) return [];
|
if (!res.ok) return [];
|
||||||
return await res.json();
|
return await res.json();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -22,22 +24,80 @@ async function getCategories() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch mega menu on the server — eliminates client-side flash
|
||||||
|
async function getMegaMenu(): Promise<MegaMenuPayload[]> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/menus/main`, { next: { revalidate: 300 } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json() as { payload?: MegaMenuPayload[] };
|
||||||
|
return Array.isArray(data.payload) ? data.payload : [];
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch mega menu in layout', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch footer menus (explore + support) on the server
|
||||||
|
async function getFooterMenus(): Promise<{ exploreMenu: MenuItem[]; supportMenu: MenuItem[] }> {
|
||||||
|
const fetchMenu = async (slug: string): Promise<MenuItem[]> => {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/menus/${slug}`, { next: { revalidate: 300 } });
|
||||||
|
if (!res.ok) return [];
|
||||||
|
const data = await res.json() as { items?: MenuItem[] };
|
||||||
|
return Array.isArray(data.items)
|
||||||
|
? data.items.sort((a, b) => a.order - b.order)
|
||||||
|
: [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const [exploreMenu, supportMenu] = await Promise.all([
|
||||||
|
fetchMenu('footer-explore'),
|
||||||
|
fetchMenu('footer-support'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { exploreMenu, supportMenu };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch store settings (social links, footer texts) on the server
|
||||||
|
async function getStoreSettings(): Promise<StoreSettings | null> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/settings`, { next: { revalidate: 600 } });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
return await res.json() as StoreSettings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch store settings in layout', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default async function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const categories = await getCategories();
|
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
||||||
|
getCategories(),
|
||||||
|
getMegaMenu(),
|
||||||
|
getFooterMenus(),
|
||||||
|
getStoreSettings(),
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html lang="th" className="scroll-smooth">
|
<html lang="th" className="scroll-smooth">
|
||||||
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||||
<ClientProviders>
|
<ClientProviders>
|
||||||
<Navbar categories={categories} />
|
<Navbar categories={categories} initialMenu={initialMenu} />
|
||||||
<main className="pt-[72px]">
|
<main className="pt-[72px]">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<Footer categories={categories} />
|
<Footer
|
||||||
|
categories={categories}
|
||||||
|
exploreMenu={footerMenus.exploreMenu}
|
||||||
|
supportMenu={footerMenus.supportMenu}
|
||||||
|
settings={settings}
|
||||||
|
/>
|
||||||
</ClientProviders>
|
</ClientProviders>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer
|
|||||||
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
function orderLabel(id: number) {
|
function orderLabel(id: number) {
|
||||||
return `#${id.toString().padStart(4, '0')}`
|
return `#${id.toString().padStart(4, '0')}`
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
|||||||
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
|
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
|
||||||
import { getCategories, getCmsPage } from '../lib/cms'
|
import { getCategories, getCmsPage } from '../lib/cms'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export async function generateMetadata(): Promise<Metadata> {
|
export async function generateMetadata(): Promise<Metadata> {
|
||||||
const page = await getCmsPage('home', lang)
|
const page = await getCmsPage('home', lang)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, ge
|
|||||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../../lib/localization'
|
import { resolveLocalized } from '../../../lib/localization'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
||||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import ProfileClient from '../../components/ProfileClient'
|
|||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
|||||||
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Scent Guide | HomAroma',
|
title: 'Scent Guide | HomAroma',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
|||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../lib/localization'
|
import { resolveLocalized } from '../../lib/localization'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Search Products | HomAroma',
|
title: 'Search Products | HomAroma',
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
|||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
import { resolveLocalized } from '../../lib/localization'
|
import { resolveLocalized } from '../../lib/localization'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Shop Aroma Products | HomAroma',
|
title: 'Shop Aroma Products | HomAroma',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import TrackOrderClient from '../../components/TrackOrderClient'
|
|||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import WishlistClient from '../../components/WishlistClient'
|
|||||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||||
|
|
||||||
const lang = 'th'
|
const lang = 'all'
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: 'Wishlist | HomAroma',
|
title: 'Wishlist | HomAroma',
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import Link from 'next/link'
|
|||||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||||
import { useCartStore } from '../store/cartStore'
|
import { useCartStore } from '../store/cartStore'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
import { resolveLocalized } from '../lib/localization'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLang } from '../hooks/useLang'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
product: Product
|
product: Product
|
||||||
@@ -13,11 +15,13 @@ interface Props {
|
|||||||
export default function AddToCartSection({ product }: Props) {
|
export default function AddToCartSection({ product }: Props) {
|
||||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||||
const { addToCart, toggleCart } = useCartStore()
|
const { addToCart, toggleCart } = useCartStore()
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const lang = useLang()
|
||||||
|
|
||||||
const currentVariant = product.variants[selectedVariant]
|
const currentVariant = product.variants[selectedVariant]
|
||||||
const categoryName = resolveLocalized(product.category.name)
|
const categoryName = resolveLocalized(product.category.name, lang)
|
||||||
const productName = resolveLocalized(product.name) || product.scent.name
|
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||||
const description = resolveLocalized(product.description)
|
const description = resolveLocalized(product.description, lang)
|
||||||
|
|
||||||
const handleAddToCart = () => {
|
const handleAddToCart = () => {
|
||||||
if (!currentVariant) return
|
if (!currentVariant) return
|
||||||
|
|||||||
@@ -1,46 +1,135 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Category } from '@homaroma/types'
|
import type { Category, MenuItem, StoreSettings } from '@homaroma/types'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
import { resolveLocalized } from '../lib/localization'
|
||||||
|
import { useLang } from '../hooks/useLang'
|
||||||
|
|
||||||
interface FooterProps {
|
interface FooterProps {
|
||||||
categories?: Category[]
|
categories?: Category[]
|
||||||
|
exploreMenu?: MenuItem[]
|
||||||
|
supportMenu?: MenuItem[]
|
||||||
|
settings?: StoreSettings | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Footer({ categories = [] }: FooterProps) {
|
export default function Footer({
|
||||||
|
categories = [],
|
||||||
|
exploreMenu = [],
|
||||||
|
supportMenu = [],
|
||||||
|
settings = null,
|
||||||
|
}: FooterProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const lang = useLang()
|
||||||
|
|
||||||
|
// Translate a menu item label using its translations array or i18n key map
|
||||||
|
const translateMenuLabel = (item: MenuItem): string => {
|
||||||
|
// Use outer `lang` from closure — hooks cannot be called inside nested functions
|
||||||
|
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
||||||
|
const trans = item.translations.find((tr: any) => tr.langCode === lang)
|
||||||
|
if (trans?.label) return trans.label
|
||||||
|
}
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
'Home': 'nav.home',
|
||||||
|
'Shop': 'nav.shop',
|
||||||
|
'Scent Guide': 'nav.scentGuide',
|
||||||
|
'About Us': 'nav.about',
|
||||||
|
'About': 'nav.about',
|
||||||
|
'FAQ': 'nav.faq',
|
||||||
|
'Contact': 'nav.contact',
|
||||||
|
'Contact Us': 'nav.contact',
|
||||||
|
'Track Order': 'nav.trackOrder',
|
||||||
|
'Delivery & Returns': 'nav.delivery',
|
||||||
|
}
|
||||||
|
return map[item.label] ? t(map[item.label]) : item.label
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social links — use settings if available, fallback to placeholder buttons
|
||||||
|
const hasSocialLinks = settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
|
<footer className="border-t border-gray-200 bg-white px-6 py-20 text-pop-black">
|
||||||
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 md:grid-cols-4">
|
<div className="mx-auto grid max-w-7xl grid-cols-1 gap-12 md:grid-cols-4">
|
||||||
|
|
||||||
|
{/* Col 1 — Brand */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-2xl font-bold tracking-widest">
|
<div className="text-2xl font-bold tracking-widest">
|
||||||
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-gray-600">
|
<p className="text-sm leading-relaxed text-gray-600">
|
||||||
{t('footer.catchphrase')}
|
{settings?.footerDescription || t('footer.catchphrase')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{['IG', 'FB', 'TW', 'YT'].map((s) => (
|
{hasSocialLinks ? (
|
||||||
<a key={s} href="#" className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white">
|
<>
|
||||||
{s}
|
{settings!.facebookUrl && (
|
||||||
</a>
|
<a
|
||||||
))}
|
href={settings!.facebookUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||||
|
>
|
||||||
|
FB
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{settings!.instagramUrl && (
|
||||||
|
<a
|
||||||
|
href={settings!.instagramUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||||
|
>
|
||||||
|
IG
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{settings!.tiktokUrl && (
|
||||||
|
<a
|
||||||
|
href={settings!.tiktokUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||||
|
>
|
||||||
|
TT
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||||
|
<a
|
||||||
|
key={s}
|
||||||
|
href="#"
|
||||||
|
className="flex h-9 w-9 items-center justify-center rounded-full border border-gray-200 bg-pop-bg text-[10px] font-bold text-pop-black transition-all duration-300 hover:border-pop-pink hover:bg-pop-pink hover:text-white"
|
||||||
|
>
|
||||||
|
{s}
|
||||||
|
</a>
|
||||||
|
))
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Col 2 — Explore */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('footer.explore')}</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('footer.explore')}</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||||
<Link href="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
|
{exploreMenu.length > 0 ? (
|
||||||
<Link href="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
|
exploreMenu.map((item) => (
|
||||||
<Link href="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
|
<Link key={item.id} href={item.url} className="transition-colors hover:text-pop-pink">
|
||||||
<Link href="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
|
{translateMenuLabel(item)}
|
||||||
<Link href="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
|
</Link>
|
||||||
<Link href="/faq" className="transition-colors hover:text-pop-pink">{t('nav.faq')}</Link>
|
))
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Link href="/" className="transition-colors hover:text-pop-pink">{t('nav.home')}</Link>
|
||||||
|
<Link href="/shop" className="transition-colors hover:text-pop-pink">{t('nav.shop')}</Link>
|
||||||
|
<Link href="/scent-guide" className="transition-colors hover:text-pop-pink">{t('nav.scentGuide')}</Link>
|
||||||
|
<Link href="/about" className="transition-colors hover:text-pop-pink">{t('nav.about')}</Link>
|
||||||
|
<Link href="/contact" className="transition-colors hover:text-pop-pink">{t('nav.contact')}</Link>
|
||||||
|
<Link href="/faq" className="transition-colors hover:text-pop-pink">{t('nav.faq')}</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Col 3 — Collections */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('nav.collections')}</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('nav.collections')}</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||||
@@ -52,23 +141,38 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-6">
|
{/* Col 4 — Support & Legal */}
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">Newsletter</h4>
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-gray-500">Subscribe for exclusive releases and sensory wisdom.</p>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">
|
||||||
<div className="flex rounded-xl border border-gray-200 bg-pop-bg p-1">
|
{settings?.footerSupportHeading
|
||||||
<input
|
? (typeof settings.footerSupportHeading === 'string'
|
||||||
type="email"
|
? settings.footerSupportHeading
|
||||||
placeholder="Your email"
|
: settings.footerSupportHeading[lang]
|
||||||
className="w-full border-none bg-transparent p-2 text-sm text-pop-black outline-none placeholder:text-gray-400"
|
|| settings.footerSupportHeading.en
|
||||||
/>
|
|| 'Support & Legal')
|
||||||
<button className="shrink-0 rounded-lg bg-pop-black px-4 py-2 text-xs font-bold uppercase text-white transition-colors hover:bg-pop-pink">
|
: t('footer.support', 'Support & Legal')}
|
||||||
Join
|
</h4>
|
||||||
</button>
|
<div className="flex flex-col space-y-2 text-sm text-gray-600">
|
||||||
|
{supportMenu.map((item) => (
|
||||||
|
<Link key={item.id} href={item.url} className="transition-colors hover:text-pop-pink">
|
||||||
|
{translateMenuLabel(item)}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4">
|
||||||
|
<p className="text-[10px] uppercase tracking-widest text-gray-500">
|
||||||
|
{settings?.footerCopyrightText
|
||||||
|
? (typeof settings.footerCopyrightText === 'string'
|
||||||
|
? settings.footerCopyrightText
|
||||||
|
: settings.footerCopyrightText[lang]
|
||||||
|
|| settings.footerCopyrightText.en
|
||||||
|
|| `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`)
|
||||||
|
: `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] uppercase tracking-widest text-gray-500">
|
|
||||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,13 +10,14 @@ import { useWishlistStore } from '../store/wishlistStore'
|
|||||||
import { useAuthStore } from '../store/authStore'
|
import { useAuthStore } from '../store/authStore'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
import { resolveLocalized } from '../lib/localization'
|
||||||
import { API_BASE } from '../lib/api'
|
import { useLang } from '../hooks/useLang'
|
||||||
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
||||||
import { MobileDrawer } from './navigation/MobileDrawer'
|
import { MobileDrawer } from './navigation/MobileDrawer'
|
||||||
import type { MegaMenuPayload } from './navigation/menuTypes'
|
import type { MegaMenuPayload } from './navigation/menuTypes'
|
||||||
|
|
||||||
interface NavbarProps {
|
interface NavbarProps {
|
||||||
categories?: Category[]
|
categories?: Category[]
|
||||||
|
initialMenu?: MegaMenuPayload[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const LANGUAGES = [
|
const LANGUAGES = [
|
||||||
@@ -58,14 +59,14 @@ function createFallbackMenu(categories: Category[]): MegaMenuPayload[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Navbar({ categories = [] }: NavbarProps) {
|
export default function Navbar({ categories = [], initialMenu = [] }: NavbarProps) {
|
||||||
const [searchOpen, setSearchOpen] = useState(false)
|
const [searchOpen, setSearchOpen] = useState(false)
|
||||||
const [mobileOpen, setMobileOpen] = useState(false)
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
||||||
const [searchQuery, setSearchQuery] = useState('')
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
const [scrolled, setScrolled] = useState(false)
|
const [scrolled, setScrolled] = useState(false)
|
||||||
const [megaMenu, setMegaMenu] = useState<MegaMenuPayload[]>([])
|
// Seed from server-fetched initialMenu — no client flash
|
||||||
const [menuLoaded, setMenuLoaded] = useState(false)
|
const [megaMenu] = useState<MegaMenuPayload[]>(initialMenu)
|
||||||
const navRef = useRef<HTMLElement>(null)
|
const navRef = useRef<HTMLElement>(null)
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const pathname = usePathname()
|
const pathname = usePathname()
|
||||||
@@ -76,10 +77,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
const logout = useAuthStore((state) => state.logout)
|
const logout = useAuthStore((state) => state.logout)
|
||||||
const cartCount = getCartCount()
|
const cartCount = getCartCount()
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const lang = i18n.language ? i18n.language.substring(0, 2) : 'th'
|
const lang = useLang()
|
||||||
|
|
||||||
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
|
const fallbackMenu = useMemo(() => createFallbackMenu(categories), [categories])
|
||||||
const menuItems = menuLoaded && megaMenu.length > 0 ? megaMenu : fallbackMenu
|
// Use server menu if available, fall back to categories-based menu
|
||||||
|
const menuItems = megaMenu.length > 0 ? megaMenu : fallbackMenu
|
||||||
|
|
||||||
const changeLanguage = (code: string) => {
|
const changeLanguage = (code: string) => {
|
||||||
i18n.changeLanguage(code)
|
i18n.changeLanguage(code)
|
||||||
@@ -96,30 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
router.push('/profile')
|
router.push('/profile')
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let isMounted = true
|
|
||||||
|
|
||||||
async function fetchMegaMenu() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`${API_BASE}/menus/main`)
|
|
||||||
if (!response.ok) throw new Error(`menus/main returned ${response.status}`)
|
|
||||||
const data = await response.json() as { payload?: MegaMenuPayload[] }
|
|
||||||
if (isMounted) {
|
|
||||||
setMegaMenu(Array.isArray(data.payload) ? data.payload : [])
|
|
||||||
setMenuLoaded(true)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[website-menu] Failed to fetch main menu', error)
|
|
||||||
if (isMounted) setMenuLoaded(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchMegaMenu()
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
isMounted = false
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onScroll = () => setScrolled(window.scrollY > 20)
|
const onScroll = () => setScrolled(window.scrollY > 20)
|
||||||
@@ -342,7 +321,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
|||||||
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-pop-black/55">{t('nav.search.quickLinks')}</p>
|
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-pop-black/55">{t('nav.search.quickLinks')}</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
{[
|
{[
|
||||||
...categories.map((category) => ({ label: resolveLocalized(category.name), to: `/category/${category.id}` })),
|
...categories.map((category) => ({ label: resolveLocalized(category.name, lang), to: `/category/${category.id}` })),
|
||||||
{ label: t('nav.scentGuide'), to: '/scent-guide' },
|
{ label: t('nav.scentGuide'), to: '/scent-guide' },
|
||||||
{ label: t('nav.search.allProducts'), to: '/shop' },
|
{ label: t('nav.search.allProducts'), to: '/shop' },
|
||||||
].map((item) => (
|
].map((item) => (
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import type { Product } from '@homaroma/types'
|
import type { Product } from '@homaroma/types'
|
||||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||||
import { resolveLocalized } from '../lib/localization'
|
import { resolveLocalized } from '../lib/localization'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useLang } from '../hooks/useLang'
|
||||||
import WishlistButton from './WishlistButton'
|
import WishlistButton from './WishlistButton'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -9,8 +13,10 @@ interface ProductCardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product }: ProductCardProps) {
|
||||||
const categoryName = resolveLocalized(product.category.name)
|
const { i18n } = useTranslation()
|
||||||
const productName = resolveLocalized(product.name) || product.scent.name
|
const lang = useLang()
|
||||||
|
const categoryName = resolveLocalized(product.category.name, lang)
|
||||||
|
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||||
const firstPrice = product.variants[0]?.price ?? product.price
|
const firstPrice = product.variants[0]?.price ?? product.price
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
|
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
|
||||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||||
import { getCategoryImage } from '@homaroma/types'
|
import { getCategoryImage } from '@homaroma/types'
|
||||||
import ProductCard from '../ProductCard'
|
import ProductCard from '../ProductCard'
|
||||||
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
|
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
|
||||||
|
import { useLang } from '../../hooks/useLang'
|
||||||
|
|
||||||
type DynamicPageRendererProps = {
|
type DynamicPageRendererProps = {
|
||||||
sections: PageSection[]
|
sections: PageSection[]
|
||||||
categories: Category[]
|
categories: Category[]
|
||||||
lang?: string
|
lang?: string // optional override; defaults to i18n.language
|
||||||
}
|
}
|
||||||
|
|
||||||
function categoryName(category: Category, lang: string) {
|
function categoryName(category: Category, lang: string) {
|
||||||
@@ -540,7 +543,10 @@ function ContactInfoSection({ payload }: { payload: Record<string, unknown> }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DynamicPageRenderer({ sections, categories, lang = 'th' }: DynamicPageRendererProps) {
|
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
|
||||||
|
const i18nLang = useLang()
|
||||||
|
// Use i18n.language reactively — switches immediately when user changes language
|
||||||
|
const lang = langProp ?? i18nLang
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full flex-col">
|
<div className="flex w-full flex-col">
|
||||||
{sections.map((section) => {
|
{sections.map((section) => {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current i18n language code (2-char) in a hydration-safe way.
|
||||||
|
*
|
||||||
|
* On the server and during initial client render the value is always 'th'
|
||||||
|
* (matching the SSR output). After hydration it updates to the user's saved
|
||||||
|
* language so all components re-render with the correct locale without
|
||||||
|
* triggering a hydration mismatch.
|
||||||
|
*/
|
||||||
|
export function useLang(): string {
|
||||||
|
const { i18n } = useTranslation()
|
||||||
|
const [lang, setLang] = useState('th') // must match server default
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLang(i18n.language ? i18n.language.substring(0, 2) : 'th')
|
||||||
|
}, [i18n.language])
|
||||||
|
|
||||||
|
return lang
|
||||||
|
}
|
||||||
@@ -61,19 +61,19 @@ export async function fetchJsonResult<T>(path: string, init?: RequestInit & { ne
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategories(lang = 'th'): Promise<Category[]> {
|
export async function getCategories(lang = 'all'): Promise<Category[]> {
|
||||||
return await fetchJson<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } }) ?? []
|
return await fetchJson<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } }) ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCategoriesResult(lang = 'th'): Promise<ApiResult<Category[]>> {
|
export async function getCategoriesResult(lang = 'all'): Promise<ApiResult<Category[]>> {
|
||||||
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } })
|
return await fetchJsonResult<Category[]>(`/categories?lang=${lang}`, { next: { revalidate: 3600 } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCmsPage(slug: string, lang = 'th'): Promise<Page | null> {
|
export async function getCmsPage(slug: string, lang = 'all'): Promise<Page | null> {
|
||||||
return await fetchJson<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJson<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getCmsPageResult(slug: string, lang = 'th'): Promise<ApiResult<Page>> {
|
export async function getCmsPageResult(slug: string, lang = 'all'): Promise<ApiResult<Page>> {
|
||||||
return await fetchJsonResult<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJsonResult<Page>(`/pages/${slug}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,15 +95,15 @@ export async function getOrderResult(id: string | number): Promise<ApiResult<Ord
|
|||||||
return await fetchJsonResult<Order>(`/orders/${id}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJsonResult<Order>(`/orders/${id}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProduct(id: string | number, lang = 'th'): Promise<Product | null> {
|
export async function getProduct(id: string | number, lang = 'all'): Promise<Product | null> {
|
||||||
return await fetchJson<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJson<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductResult(id: string | number, lang = 'th'): Promise<ApiResult<Product>> {
|
export async function getProductResult(id: string | number, lang = 'all'): Promise<ApiResult<Product>> {
|
||||||
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
return await fetchJsonResult<Product>(`/products/${id}?lang=${lang}`, { next: { revalidate: defaultRevalidate } })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
|
||||||
const separator = query ? '&' : ''
|
const separator = query ? '&' : ''
|
||||||
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||||
next: { revalidate: defaultRevalidate },
|
next: { revalidate: defaultRevalidate },
|
||||||
@@ -112,7 +112,7 @@ export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
|||||||
return response?.data ?? []
|
return response?.data ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProductsResult(query = '', lang = 'th'): Promise<ApiResult<Product[]>> {
|
export async function getProductsResult(query = '', lang = 'all'): Promise<ApiResult<Product[]>> {
|
||||||
const separator = query ? '&' : ''
|
const separator = query ? '&' : ''
|
||||||
const response = await fetchJsonResult<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
const response = await fetchJsonResult<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||||
next: { revalidate: defaultRevalidate },
|
next: { revalidate: defaultRevalidate },
|
||||||
|
|||||||
+254
@@ -0,0 +1,254 @@
|
|||||||
|
# HomAroma MCP Update: `mega-showcase`
|
||||||
|
|
||||||
|
เอกสารนี้สรุปผลการใช้งาน `homaroma_mcp` กับหน้า CMS `mega-showcase` เพื่อส่งต่อให้ทีม Dev ของ MCP Tool และ Backend ใช้พัฒนาต่อ
|
||||||
|
|
||||||
|
## เป้าหมายงาน
|
||||||
|
|
||||||
|
- ต้องการให้หน้า CMS `mega-showcase` แปลภาษาให้ครบทุก section
|
||||||
|
- ต้องทำผ่าน MCP Tool เท่านั้น
|
||||||
|
- ห้ามใช้ workaround ด้วยการแก้โค้ด, แก้ DB, หรือ patch ข้อมูลนอก MCP
|
||||||
|
- ถ้า MCP ไม่รองรับบางส่วน ต้องระบุข้อจำกัดให้ชัดเจน
|
||||||
|
|
||||||
|
## สิ่งที่ตรวจสอบแล้วก่อนลงมือ
|
||||||
|
|
||||||
|
- ตรวจสอบหน้า dynamic page ด้วย `get_dynamic_page_detail(slug: "mega-showcase")`
|
||||||
|
- ตรวจสอบ schema section ที่เกี่ยวข้องใน:
|
||||||
|
- `packages/types/index.ts`
|
||||||
|
- `apps/backend/src/routes/pages.ts`
|
||||||
|
- `apps/mcp-server/index.ts`
|
||||||
|
- ยืนยันแล้วว่า `LocalizedStringSchema` รองรับทั้ง string และ object map ของภาษา
|
||||||
|
- ยืนยันแล้วว่า section types บางตัวรองรับ localized payload โดยตรง
|
||||||
|
|
||||||
|
## สรุปผลที่ทำสำเร็จผ่าน MCP
|
||||||
|
|
||||||
|
### 1) Page translation
|
||||||
|
|
||||||
|
อัปเดต translation ของหน้า `mega-showcase` สำเร็จครบ 6 ภาษา
|
||||||
|
|
||||||
|
- `th`: `หน้าเมก้าโชว์เคส`
|
||||||
|
- `en`: `Mega Showcase Page`
|
||||||
|
- `ja`: `メガショーケースページ`
|
||||||
|
- `zh`: `超级展示页面`
|
||||||
|
- `vi`: `Trang Mega Showcase`
|
||||||
|
- `ko`: `메가 쇼케이스 페이지`
|
||||||
|
|
||||||
|
### 2) Product translation
|
||||||
|
|
||||||
|
สินค้า `id: 16` ซึ่งอยู่ใน `PRODUCTS` section ถูกแปลครบ 6 ภาษาแล้ว
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `description`
|
||||||
|
- `details`
|
||||||
|
|
||||||
|
Translation records ถูกบันทึกจริงในระบบและยืนยันได้จาก `get_product_detail(id: 16, lang: "all")`
|
||||||
|
|
||||||
|
### 3) Sections ที่อัปเดตสำเร็จ
|
||||||
|
|
||||||
|
#### Section 87 - `HERO`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `tagline`
|
||||||
|
- `description`
|
||||||
|
- `buttonText`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `imageUrl`
|
||||||
|
- `buttonLink`
|
||||||
|
|
||||||
|
#### Section 88 - `CATEGORIES`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `subtitle`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `categoryIds`
|
||||||
|
|
||||||
|
#### Section 89 - `PRODUCTS`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `type`
|
||||||
|
- `limit`
|
||||||
|
- `products`
|
||||||
|
|
||||||
|
รายการสินค้าใน block นี้ประกอบด้วย `1, 6, 11, 16` และสินค้า `16` ถูกแปลครบแล้วในระดับ product translation
|
||||||
|
|
||||||
|
#### Section 90 - `BANNER`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `imageUrl`
|
||||||
|
|
||||||
|
#### Section 91 - `TEXT`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `content`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `textAlign`
|
||||||
|
|
||||||
|
#### Section 92 - `ANNOUNCEMENT_BAR`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `slides[0]`
|
||||||
|
- `slides[1]`
|
||||||
|
|
||||||
|
#### Section 93 - `NEWSLETTER`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
- `buttonText`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `showPrivacyCheckbox`
|
||||||
|
|
||||||
|
#### Section 94 - `EDITORIAL_TILE`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `description`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `imageUrl`
|
||||||
|
- `imagePosition`
|
||||||
|
|
||||||
|
#### Section 95 - `EDITORIAL_CAROUSEL`
|
||||||
|
|
||||||
|
อัปเดตได้สำเร็จ:
|
||||||
|
|
||||||
|
- `title`
|
||||||
|
- `slides[0].category`
|
||||||
|
- `slides[0].title`
|
||||||
|
- `slides[0].description`
|
||||||
|
|
||||||
|
คงค่าเดิมไว้:
|
||||||
|
|
||||||
|
- `imageUrl`
|
||||||
|
|
||||||
|
## สถานะหลังอัปเดต
|
||||||
|
|
||||||
|
### ยืนยันจาก MCP ว่าบันทึกสำเร็จ
|
||||||
|
|
||||||
|
- `get_product_detail(id: 16, lang: "all")` แสดง translation records ครบจริง
|
||||||
|
- `get_dynamic_page_detail(slug: "mega-showcase")` แสดง payload ของ section ที่อัปเดตแล้วเป็น multilingual object จริง
|
||||||
|
|
||||||
|
### ตัวอย่างข้อมูลที่ยืนยันได้
|
||||||
|
|
||||||
|
- Hero payload เก็บเป็น object แยกภาษาแล้ว
|
||||||
|
- Categories payload เก็บเป็น object แยกภาษาแล้ว
|
||||||
|
- Announcement bar slides เก็บเป็น object แยกภาษาแล้ว
|
||||||
|
- Newsletter / Editorial / Banner / Text เก็บเป็น object แยกภาษาแล้ว
|
||||||
|
|
||||||
|
## Sections ที่ปิด gap แล้ว
|
||||||
|
|
||||||
|
หลังจากขยาย backend validation และเพิ่ม UI/renderer ให้รองรับ payload แบบ localized แล้ว section ต่อไปนี้ใช้งาน multilingual ได้ครบในทางปฏิบัติ:
|
||||||
|
|
||||||
|
- `96 - HERO_ARTICLE`
|
||||||
|
- `97 - RICH_TEXT`
|
||||||
|
- `98 - SCENT_NOTES_BREAKDOWN`
|
||||||
|
- `99 - QUOTE_CALLOUT`
|
||||||
|
- `100 - PRODUCT_CAROUSEL`
|
||||||
|
- `101 - ACCORDION`
|
||||||
|
- `102 - CONTACT_INFO`
|
||||||
|
- `103 - CONTACT_FORM`
|
||||||
|
|
||||||
|
### สิ่งที่ปิด gap ให้ครบ
|
||||||
|
|
||||||
|
- `apps/backend/src/routes/pages.ts` รับ payload ของ section เหล่านี้ได้แล้วผ่าน validation แบบ localized-friendly
|
||||||
|
- `apps/admin` มี form สำหรับสร้าง/แก้ block เหล่านี้แล้ว
|
||||||
|
- `apps/spa` แสดงผล block เหล่านี้ตามภาษา active ได้แล้ว
|
||||||
|
|
||||||
|
## จุดที่ยังควรยกระดับสำหรับ LLM
|
||||||
|
|
||||||
|
แม้ section gap จะปิดแล้ว แต่ถ้าเป้าหมายคือทำให้ LLM ใช้งาน MCP ได้ดีที่สุด ยังมีงานที่ควรทำต่อ:
|
||||||
|
|
||||||
|
### 1) เพิ่ม tool แบบเฉพาะ block
|
||||||
|
|
||||||
|
ตอนนี้มี tool เฉพาะ block เพิ่มแล้ว โดยยังคง `add_page_section` และ `update_page_section` เป็น fallback:
|
||||||
|
|
||||||
|
- `update_hero_article`
|
||||||
|
- `update_rich_text`
|
||||||
|
- `update_scent_notes_breakdown`
|
||||||
|
- `update_quote_callout`
|
||||||
|
- `update_product_carousel`
|
||||||
|
- `update_accordion`
|
||||||
|
- `update_contact_info`
|
||||||
|
- `update_contact_form`
|
||||||
|
|
||||||
|
ถ้าจะเพิ่มฝั่ง create แบบเฉพาะ block ต่อไป ค่อยขยายเป็นคู่ `add_*` เพิ่มจากชุดนี้ได้อีกชั้น
|
||||||
|
|
||||||
|
### 2) ทำ structured read-back
|
||||||
|
|
||||||
|
ตอนนี้ update tools คืนข้อมูลแบบ normalize แล้วใน JSON response
|
||||||
|
|
||||||
|
ถ้าจะยกระดับต่อ ควรแยกให้ชัดขึ้นอีก:
|
||||||
|
|
||||||
|
- field ไหนถูก merge
|
||||||
|
- field ไหนถูก overwrite
|
||||||
|
- payload สุดท้ายหน้าตาเป็นอะไร
|
||||||
|
|
||||||
|
### 3) ทำ error ให้ LLM-friendly
|
||||||
|
|
||||||
|
validation error ควรบอก:
|
||||||
|
|
||||||
|
- `field`
|
||||||
|
- `expected`
|
||||||
|
- `got`
|
||||||
|
- `suggestion`
|
||||||
|
|
||||||
|
> ตอนนี้ backend/MCP ยังคืน error เป็นข้อความรวมอยู่เป็นหลัก จึงยังมีช่องให้ขยายต่อในรอบถัดไป
|
||||||
|
|
||||||
|
### 4) ชัดเจนเรื่อง supported shape
|
||||||
|
|
||||||
|
tool description ควรบอกตัวอย่าง payload ที่คาดหวังสำหรับแต่ละ block ให้ชัดขึ้น เพื่อให้ LLM ไม่ต้องเดาจาก schema ล้วน ๆ
|
||||||
|
|
||||||
|
## ผลกระทบกับ user-facing CMS
|
||||||
|
|
||||||
|
ตอนนี้หน้า `mega-showcase` สามารถแปล section หลักได้ครบมากขึ้นแล้ว และ block ที่เคยค้างอยู่ฝั่ง data shape ก็ปิด gap ไปแล้ว
|
||||||
|
|
||||||
|
สิ่งที่เหลือเป็นเรื่องของ UX สำหรับผู้ใช้เครื่องมือและความชัดเจนของ MCP tool layer มากกว่าความสามารถในการ render จริง
|
||||||
|
|
||||||
|
## จุดที่ควรทดสอบเพิ่ม
|
||||||
|
|
||||||
|
- เปิดหน้า `mega-showcase` ใน SPA แล้วสลับภาษาเพื่อตรวจ render จริง
|
||||||
|
- ตรวจว่าภาษา active ถูก resolve จาก object map ใน section ที่อัปเดตแล้ว
|
||||||
|
- ตรวจว่า product `16` แสดงชื่อ/description/details ตามภาษาที่เลือกจริง
|
||||||
|
- ตรวจว่า block ใหม่ทั้ง 8 แบบ บันทึกและแก้ไขได้ต่อเนื่องผ่าน admin แล้วไม่ทำให้หน้า error เมื่อสลับภาษา
|
||||||
|
|
||||||
|
## รายการ Tools ที่เกี่ยวข้อง
|
||||||
|
|
||||||
|
- `get_dynamic_page_detail`
|
||||||
|
- `update_page_translation`
|
||||||
|
- `update_page_section`
|
||||||
|
- `get_product_detail`
|
||||||
|
- `update_product_translation`
|
||||||
|
|
||||||
|
## บทสรุปสำหรับทีม Dev
|
||||||
|
|
||||||
|
MCP Tool ของ HomAroma ตอนนี้ใช้งานได้จริงมากขึ้นสำหรับการแปลหน้า CMS `mega-showcase`
|
||||||
|
|
||||||
|
gap ของ section ที่เคยค้างอยู่ถูกปิดด้วยการขยาย backend validation, admin forms, และ spa renderer แล้ว
|
||||||
|
|
||||||
|
ถ้าเป้าหมายถัดไปคือให้ LLM ใช้งาน MCP ได้ “ดีมาก” ไม่ใช่แค่ “ทำงานได้” ควรเพิ่ม dedicated tools, structured read-back, และ error messages ที่สื่อกับโมเดลได้ตรงขึ้น
|
||||||
Reference in new issue
Block a user