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 BannerForm from '../components/page-builder/BannerForm';
|
||||
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 ContactInfoForm from '../components/page-builder/ContactInfoForm';
|
||||
import ContactFormForm from '../components/page-builder/ContactFormForm';
|
||||
@@ -531,6 +536,50 @@ export default function PageBuilderPage() {
|
||||
textAlign: 'center'
|
||||
};
|
||||
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':
|
||||
defaultPayload = {
|
||||
title: 'Frequently Asked Questions',
|
||||
@@ -791,7 +840,12 @@ export default function PageBuilderPage() {
|
||||
)}
|
||||
</div>
|
||||
<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>
|
||||
{section.type === 'PRODUCTS' && (
|
||||
<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="BANNER">BANNER (Promo Image call-to-action)</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="CONTACT_INFO">CONTACT INFO (Location/Hours)</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' && (
|
||||
<AccordionForm
|
||||
editingSection={editingSection}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import prisma from "../lib/prisma";
|
||||
import {
|
||||
LocalizedStringSchema,
|
||||
HeroSectionPayloadSchema,
|
||||
CategoriesSectionPayloadSchema,
|
||||
ProductsSectionPayloadSchema,
|
||||
@@ -21,6 +22,122 @@ import {
|
||||
} from "@homaroma/types";
|
||||
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 = [
|
||||
"shop", "category", "products", "cart", "checkout", "account", "search",
|
||||
"login", "admin", "wishlist", "track", "coupons", "scent-guide",
|
||||
@@ -28,6 +145,19 @@ const RESERVED_SLUGS = [
|
||||
"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" })
|
||||
.get("/", async () => {
|
||||
return await prisma.page.findMany({
|
||||
@@ -125,6 +255,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
categories = await prisma.category.findMany({
|
||||
where: { id: { in: ids } },
|
||||
include: {
|
||||
translations: true,
|
||||
_count: {
|
||||
select: { products: true }
|
||||
}
|
||||
@@ -134,13 +265,18 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
} else {
|
||||
categories = await prisma.category.findMany({
|
||||
include: {
|
||||
translations: true,
|
||||
_count: {
|
||||
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 {
|
||||
@@ -452,43 +588,43 @@ function validateSectionPayload(type: string, payload: any): string | null {
|
||||
break;
|
||||
}
|
||||
case 'HERO_ARTICLE': {
|
||||
const res = HeroArticlePayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateHeroArticlePayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'RICH_TEXT': {
|
||||
const res = RichTextPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateRichTextPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'SCENT_NOTES_BREAKDOWN': {
|
||||
const res = ScentNotesBreakdownPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateScentNotesBreakdownPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'QUOTE_CALLOUT': {
|
||||
const res = QuoteCalloutPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateQuoteCalloutPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'PRODUCT_CAROUSEL': {
|
||||
const res = ProductCarouselPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateProductCarouselPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'ACCORDION': {
|
||||
const res = AccordionSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateAccordionPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'CONTACT_INFO': {
|
||||
const res = ContactInfoSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateContactInfoPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
case 'CONTACT_FORM': {
|
||||
const res = ContactFormSectionPayloadSchema.safeParse(payload);
|
||||
if (!res.success) return res.error.issues.map((e: any) => `${e.path.join('.')}: ${e.message}`).join(', ');
|
||||
const error = validateContactFormPayload(payload);
|
||||
if (error) return error;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -1,15 +1,35 @@
|
||||
# mcp-server
|
||||
# HomAroma MCP Server
|
||||
|
||||
To install dependencies:
|
||||
MCP server สำหรับเชื่อม AI tools เข้ากับ backend ของ HomAroma ผ่าน stdio transport
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
bun install
|
||||
bun run --cwd apps/mcp-server dev
|
||||
```
|
||||
|
||||
To run:
|
||||
หรือรันตรงจาก package:
|
||||
|
||||
```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;
|
||||
}
|
||||
|
||||
let BACKEND_URL = "http://localhost:3051/api";
|
||||
const ADMIN_USERNAME = process.env.ADMIN_USERNAME || "admin";
|
||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "homaroma123"; // เปลี่ยนรหัสผ่านตรงนี้ให้ตรงกับ Production ด้วยนะครับ
|
||||
const localizedStringSchema = {
|
||||
oneOf: [
|
||||
{ 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 = "";
|
||||
|
||||
@@ -442,6 +629,15 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
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",
|
||||
description: "Delete a block section from a dynamic page layout",
|
||||
@@ -569,9 +765,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
case "set_target_environment": {
|
||||
const env = params.env;
|
||||
if (env === "production") {
|
||||
BACKEND_URL = "https://api.homaroma.shop/api";
|
||||
BACKEND_URL = PRODUCTION_BACKEND_URL;
|
||||
} else {
|
||||
BACKEND_URL = "http://localhost:3051/api";
|
||||
BACKEND_URL = LOCAL_BACKEND_URL;
|
||||
}
|
||||
api.defaults.baseURL = BACKEND_URL;
|
||||
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.isActive !== undefined) payload.isActive = params.isActive;
|
||||
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": {
|
||||
const res = await api.delete(`/pages/sections/${params.sectionId}`);
|
||||
@@ -1043,4 +1260,4 @@ async function main() {
|
||||
console.error("HomAroma MCP Server running on stdio");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
main().catch(console.error);
|
||||
@@ -2,6 +2,11 @@
|
||||
"name": "mcp-server",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --watch index.ts",
|
||||
"start": "bun index.ts",
|
||||
"check": "bunx tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#!/bin/bash
|
||||
cd /home/kitisak/Projects/homaroma/apps/mcp-server
|
||||
/home/kitisak/.bun/bin/bun run index.ts
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
bun run index.ts
|
||||
@@ -49,7 +49,13 @@ export default function DynamicPageRenderer({
|
||||
/>
|
||||
);
|
||||
case 'PRODUCTS':
|
||||
return <DynamicProducts key={section.id} payload={section.payload} />;
|
||||
return (
|
||||
<DynamicProducts
|
||||
key={section.id}
|
||||
payload={section.payload}
|
||||
categories={categories}
|
||||
/>
|
||||
);
|
||||
case 'BANNER':
|
||||
return <DynamicBanner key={section.id} payload={section.payload} />;
|
||||
case 'TEXT':
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Link } from 'react-router-dom';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Pagination } from 'swiper/modules';
|
||||
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 { getLocalizedField } from '../utils/localization';
|
||||
|
||||
@@ -12,14 +12,16 @@ import 'swiper/css/pagination';
|
||||
|
||||
interface DynamicProductsProps {
|
||||
payload: ProductsSectionPayload & { products?: Product[] };
|
||||
categories?: Category[];
|
||||
}
|
||||
|
||||
export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
const { i18n } = useTranslation();
|
||||
export default function DynamicProducts({ payload, categories = [] }: DynamicProductsProps) {
|
||||
const { i18n, t } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { products = [], layout = 'grid', enableQuickShop = false } = payload;
|
||||
const title = getLocalizedField(payload.title, lang);
|
||||
const subtitle = getLocalizedField(payload.subtitle, lang);
|
||||
const categoryLookup = new Map(categories.map((category) => [category.id, category]));
|
||||
|
||||
return (
|
||||
<section className="bg-pop-bg py-24 px-6 relative">
|
||||
@@ -37,7 +39,8 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
to="/shop"
|
||||
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
||||
>
|
||||
View All Collection →
|
||||
{t('common.viewAllCollection')}
|
||||
→
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -61,7 +64,11 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
>
|
||||
{products.map((p) => (
|
||||
<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>
|
||||
))}
|
||||
</Swiper>
|
||||
@@ -70,7 +77,11 @@ export default function DynamicProducts({ payload }: DynamicProductsProps) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{products.map((p) => (
|
||||
<div key={p.id}>
|
||||
<ProductCard product={p} enableQuickShop={enableQuickShop} />
|
||||
<ProductCard
|
||||
product={p}
|
||||
enableQuickShop={enableQuickShop}
|
||||
categoryNameOverride={getLocalizedField(categoryLookup.get(p.categoryId)?.name, lang)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -11,9 +11,21 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
|
||||
const p = payload || {};
|
||||
const title = getLocalizedField(p.title, lang);
|
||||
const description = getLocalizedField(p.description, lang);
|
||||
const topNotes = getLocalizedField(p.topNotes, lang);
|
||||
const heartNotes = getLocalizedField(p.heartNotes, lang);
|
||||
const baseNotes = getLocalizedField(p.baseNotes, lang);
|
||||
const notes = (value: any) => Array.isArray(value)
|
||||
? value.map((item) => getLocalizedField(item, lang)).filter(Boolean).join(', ')
|
||||
: 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 (
|
||||
<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="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">
|
||||
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
||||
</p>
|
||||
|
||||
@@ -16,9 +16,10 @@ function resolveStr(v: TranslatableString | null | undefined, lang: string): str
|
||||
interface ProductCardProps {
|
||||
product: Product;
|
||||
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 { addItem, removeItem, isInWishlist } = useWishlistStore();
|
||||
const { addToCart } = useCartStore();
|
||||
@@ -26,6 +27,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
const isWished = isInWishlist(product.id);
|
||||
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null);
|
||||
const displayName = resolveStr(product.name, i18n.language) || product.scent.name;
|
||||
const displayCategoryName = categoryNameOverride || resolveStr(product.category.name, i18n.language);
|
||||
|
||||
const handleWishlistClick = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -49,7 +51,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
{/* Image Area */}
|
||||
<div className="h-48 md:h-64 overflow-hidden rounded-xl relative bg-spa-sage/20">
|
||||
<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"
|
||||
alt={product.scent.name}
|
||||
onError={(e) => {
|
||||
@@ -58,7 +60,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
/>
|
||||
{/* 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">
|
||||
{typeof product.category.name === 'string' ? product.category.name : (product.category.name as any)?.[i18n.language] || (product.category.name as any)?.en || ''}
|
||||
{displayCategoryName}
|
||||
</div>
|
||||
{/* Wishlist Button */}
|
||||
<button
|
||||
@@ -79,7 +81,7 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
{displayName}
|
||||
</h3>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -136,13 +138,13 @@ export default function ProductCard({ product, enableQuickShop = false }: Produc
|
||||
price: selectedVariant.price || 0,
|
||||
productName: displayName,
|
||||
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"
|
||||
>
|
||||
<ShoppingBag className="w-3.5 h-3.5" />
|
||||
Add to Cart
|
||||
{t('cart.addToCart')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import { useState } from 'react';
|
||||
import type { AccordionSectionPayload } from '@homaroma/types';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../../utils/localization';
|
||||
|
||||
interface AccordionSectionProps {
|
||||
payload: AccordionSectionPayload;
|
||||
}
|
||||
|
||||
export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [openIndex, setOpenIndex] = useState<string | null>(null);
|
||||
const title = getLocalizedField((payload as any)?.title, lang);
|
||||
|
||||
const toggleAccordion = (index: string) => {
|
||||
setOpenIndex(openIndex === index ? null : index);
|
||||
@@ -16,11 +21,16 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||
return (
|
||||
<section className="w-full py-16 px-4 md:px-8 bg-spa-sand/20">
|
||||
<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) => (
|
||||
<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">
|
||||
{category.icon && <span className="text-spa-sage">{category.icon}</span>}
|
||||
{category.name}
|
||||
{getLocalizedField(category.name, lang)}
|
||||
</h3>
|
||||
<div className="flex flex-col gap-4">
|
||||
{category.questions.map((item, qIndex) => {
|
||||
@@ -37,7 +47,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
||||
onClick={() => toggleAccordion(indexKey)}
|
||||
>
|
||||
<span className="text-lg font-medium text-spa-charcoal pr-4">
|
||||
{item.q}
|
||||
{getLocalizedField(item.q, lang)}
|
||||
</span>
|
||||
<span className="text-spa-sage flex-shrink-0">
|
||||
{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">
|
||||
{item.a}
|
||||
{getLocalizedField(item.a, lang)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { ContactFormSectionPayload } from '@homaroma/types';
|
||||
import { Send } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../../utils/localization';
|
||||
|
||||
interface ContactFormSectionProps {
|
||||
payload: ContactFormSectionPayload;
|
||||
}
|
||||
|
||||
export default function ContactFormSection({ payload }: ContactFormSectionProps) {
|
||||
const { i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
@@ -14,6 +18,84 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
});
|
||||
const [isSubmitting, setIsSubmitting] = 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) => {
|
||||
e.preventDefault();
|
||||
@@ -40,10 +122,10 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-4">
|
||||
{payload.title || "ติดต่อเรา"}
|
||||
{title || copy.title}
|
||||
</h2>
|
||||
<p className="text-lg text-spa-charcoal/70 max-w-xl mx-auto">
|
||||
{payload.subtitle || "ส่งข้อความถึงทีมงานของเรา เรายินดีให้บริการและพร้อมตอบทุกข้อสงสัย"}
|
||||
{subtitle || copy.subtitle}
|
||||
</p>
|
||||
</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">
|
||||
<Send size={32} />
|
||||
</div>
|
||||
<h3 className="text-2xl font-serif text-spa-charcoal mb-2">ส่งข้อความสำเร็จ</h3>
|
||||
<p className="text-spa-charcoal/70">ขอบคุณที่ติดต่อเรา ทีมงานจะรีบตอบกลับโดยเร็วที่สุด</p>
|
||||
<h3 className="text-2xl font-serif text-spa-charcoal mb-2">{copy.successTitle}</h3>
|
||||
<p className="text-spa-charcoal/70">{copy.successBody}</p>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="name" className="block text-sm font-medium text-spa-charcoal/80">
|
||||
ชื่อ-นามสกุล
|
||||
{copy.name}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
@@ -71,12 +153,12 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
value={formData.name}
|
||||
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"
|
||||
placeholder="กรอกชื่อของคุณ"
|
||||
placeholder={copy.namePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="email" className="block text-sm font-medium text-spa-charcoal/80">
|
||||
อีเมล
|
||||
{copy.email}
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
@@ -93,7 +175,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="message" className="block text-sm font-medium text-spa-charcoal/80">
|
||||
ข้อความ
|
||||
{copy.message}
|
||||
</label>
|
||||
<textarea
|
||||
id="message"
|
||||
@@ -103,7 +185,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
onChange={handleChange}
|
||||
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"
|
||||
placeholder="พิมพ์ข้อความของคุณที่นี่..."
|
||||
placeholder={copy.messagePlaceholder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -117,7 +199,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
||||
) : (
|
||||
<>
|
||||
<Send size={18} />
|
||||
ส่งข้อความ
|
||||
{copy.submit}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
@@ -1,57 +1,67 @@
|
||||
|
||||
import type { ContactInfoSectionPayload } from '@homaroma/types';
|
||||
import { MapPin, Mail, MessageCircle, Clock } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getLocalizedField } from '../../utils/localization';
|
||||
|
||||
interface ContactInfoSectionProps {
|
||||
payload: ContactInfoSectionPayload;
|
||||
}
|
||||
|
||||
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 (
|
||||
<section className="w-full py-16 px-4 md:px-8 bg-white">
|
||||
<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="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">
|
||||
<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>
|
||||
{items.map(([label, value, Icon]) => {
|
||||
if (!value) return null;
|
||||
|
||||
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>
|
||||
</section>
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"home": "Home",
|
||||
"shop": "Shop",
|
||||
"viewAll": "View All →",
|
||||
"viewAllCollection": "View All Collection →",
|
||||
"backToShop": "Back to Shop"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"home": "ホーム",
|
||||
"shop": "ショップ",
|
||||
"viewAll": "すべて見る →",
|
||||
"viewAllCollection": "すべてのコレクションを見る →",
|
||||
"backToShop": "ショップに戻る"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"home": "홈",
|
||||
"shop": "쇼핑",
|
||||
"viewAll": "모두 보기 →",
|
||||
"viewAllCollection": "전체 컬렉션 보기 →",
|
||||
"backToShop": "쇼핑으로 돌아가기"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
"home": "หน้าหลัก",
|
||||
"shop": "ร้านค้า",
|
||||
"viewAll": "ดูทั้งหมด →",
|
||||
"viewAllCollection": "ดูสินค้าทั้งหมด →",
|
||||
"backToShop": "กลับไปร้านค้า"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"home": "Trang chủ",
|
||||
"shop": "Cửa hàng",
|
||||
"viewAll": "Xem tất cả →",
|
||||
"viewAllCollection": "Xem tất cả bộ sưu tập →",
|
||||
"backToShop": "Quay lại cửa hàng"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
"home": "首页",
|
||||
"shop": "商店",
|
||||
"viewAll": "查看全部 →",
|
||||
"viewAllCollection": "查看全部系列 →",
|
||||
"backToShop": "返回商店"
|
||||
},
|
||||
"product": {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Metadata } from 'next'
|
||||
import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
const reservedSlugs = new Set([
|
||||
'about',
|
||||
|
||||
@@ -9,7 +9,7 @@ import { type ApiResult, fetchJsonResult, getCategoriesResult, getCmsPageResult
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
type CategoryProductsResponse = {
|
||||
category?: Category
|
||||
|
||||
@@ -5,7 +5,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Checkout | HomAroma',
|
||||
|
||||
@@ -4,7 +4,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getActiveCouponsResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
function formatDiscount(type: string, value: number) {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
|
||||
@@ -4,16 +4,18 @@ import Navbar from '../components/Navbar';
|
||||
import Footer from '../components/Footer';
|
||||
import ClientProviders from '../components/ClientProviders';
|
||||
import { API_BASE } from '../lib/api';
|
||||
import type { MegaMenuPayload } from '../components/navigation/menuTypes';
|
||||
import type { MenuItem, StoreSettings } from '@homaroma/types';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'HomAroma',
|
||||
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() {
|
||||
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 [];
|
||||
return await res.json();
|
||||
} 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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const categories = await getCategories();
|
||||
const [categories, initialMenu, footerMenus, settings] = await Promise.all([
|
||||
getCategories(),
|
||||
getMegaMenu(),
|
||||
getFooterMenus(),
|
||||
getStoreSettings(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<html lang="th" className="scroll-smooth">
|
||||
<body className="min-h-screen bg-pop-bg text-pop-black font-sans antialiased">
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} />
|
||||
<Navbar categories={categories} initialMenu={initialMenu} />
|
||||
<main className="pt-[72px]">
|
||||
{children}
|
||||
</main>
|
||||
<Footer categories={categories} />
|
||||
<Footer
|
||||
categories={categories}
|
||||
exploreMenu={footerMenus.exploreMenu}
|
||||
supportMenu={footerMenus.supportMenu}
|
||||
settings={settings}
|
||||
/>
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -8,7 +8,7 @@ import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer
|
||||
import { getCategoriesResult, getCmsPageResult, getOrderResult } from '../../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
function orderLabel(id: number) {
|
||||
return `#${id.toString().padStart(4, '0')}`
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
|
||||
import DynamicPageRenderer from '../components/dynamic/DynamicPageRenderer'
|
||||
import { getCategories, getCmsPage } from '../lib/cms'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const page = await getCmsPage('home', lang)
|
||||
|
||||
@@ -9,7 +9,7 @@ import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, ge
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
||||
const result = await getProductsResult(`categoryId=${categoryId}`, lang)
|
||||
|
||||
@@ -5,7 +5,7 @@ import ProfileClient from '../../components/ProfileClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'โปรไฟล์สมาชิก | HomAroma',
|
||||
|
||||
@@ -3,7 +3,7 @@ import DynamicPageRenderer from '../../components/dynamic/DynamicPageRenderer'
|
||||
import { getArticlesResult, getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Scent Guide | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Search Products | HomAroma',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getCategoriesResult, getCmsPageResult, getProductsResult } from '../../
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../lib/localization'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Shop Aroma Products | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import TrackOrderClient from '../../components/TrackOrderClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'ติดตามคำสั่งซื้อ | HomAroma',
|
||||
|
||||
@@ -5,7 +5,7 @@ import WishlistClient from '../../components/WishlistClient'
|
||||
import { getCategoriesResult, getCmsPageResult } from '../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../lib/cms-sections'
|
||||
|
||||
const lang = 'th'
|
||||
const lang = 'all'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Wishlist | HomAroma',
|
||||
|
||||
@@ -5,6 +5,8 @@ import Link from 'next/link'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
@@ -13,11 +15,13 @@ interface Props {
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
const categoryName = resolveLocalized(product.category.name)
|
||||
const productName = resolveLocalized(product.name) || product.scent.name
|
||||
const description = resolveLocalized(product.description)
|
||||
const categoryName = resolveLocalized(product.category.name, lang)
|
||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||
const description = resolveLocalized(product.description, lang)
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentVariant) return
|
||||
|
||||
@@ -1,46 +1,135 @@
|
||||
"use client";
|
||||
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 { resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface FooterProps {
|
||||
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 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 (
|
||||
<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">
|
||||
|
||||
{/* Col 1 — Brand */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-2xl font-bold tracking-widest">
|
||||
HomAroma <span className="text-sm font-semibold italic text-pop-pink">Essential</span>
|
||||
</div>
|
||||
<p className="text-sm leading-relaxed text-gray-600">
|
||||
{t('footer.catchphrase')}
|
||||
{settings?.footerDescription || t('footer.catchphrase')}
|
||||
</p>
|
||||
<div className="flex gap-4">
|
||||
{['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>
|
||||
))}
|
||||
{hasSocialLinks ? (
|
||||
<>
|
||||
{settings!.facebookUrl && (
|
||||
<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>
|
||||
|
||||
{/* Col 2 — Explore */}
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
<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>
|
||||
{exploreMenu.length > 0 ? (
|
||||
exploreMenu.map((item) => (
|
||||
<Link key={item.id} href={item.url} className="transition-colors hover:text-pop-pink">
|
||||
{translateMenuLabel(item)}
|
||||
</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>
|
||||
|
||||
{/* Col 3 — Collections */}
|
||||
<div className="space-y-4">
|
||||
<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">
|
||||
@@ -52,23 +141,38 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">Newsletter</h4>
|
||||
<p className="text-xs text-gray-500">Subscribe for exclusive releases and sensory wisdom.</p>
|
||||
<div className="flex rounded-xl border border-gray-200 bg-pop-bg p-1">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="Your email"
|
||||
className="w-full border-none bg-transparent p-2 text-sm text-pop-black outline-none placeholder:text-gray-400"
|
||||
/>
|
||||
<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">
|
||||
Join
|
||||
</button>
|
||||
{/* Col 4 — Support & Legal */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">
|
||||
{settings?.footerSupportHeading
|
||||
? (typeof settings.footerSupportHeading === 'string'
|
||||
? settings.footerSupportHeading
|
||||
: settings.footerSupportHeading[lang]
|
||||
|| settings.footerSupportHeading.en
|
||||
|| 'Support & Legal')
|
||||
: t('footer.support', 'Support & Legal')}
|
||||
</h4>
|
||||
<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>
|
||||
<p className="text-[10px] uppercase tracking-widest text-gray-500">
|
||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</footer>
|
||||
)
|
||||
|
||||
@@ -10,13 +10,14 @@ import { useWishlistStore } from '../store/wishlistStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { API_BASE } from '../lib/api'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import { DesktopMegaMenu } from './navigation/DesktopMegaMenu'
|
||||
import { MobileDrawer } from './navigation/MobileDrawer'
|
||||
import type { MegaMenuPayload } from './navigation/menuTypes'
|
||||
|
||||
interface NavbarProps {
|
||||
categories?: Category[]
|
||||
initialMenu?: MegaMenuPayload[]
|
||||
}
|
||||
|
||||
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 [mobileOpen, setMobileOpen] = useState(false)
|
||||
const [langSheetOpen, setLangSheetOpen] = useState(false)
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [megaMenu, setMegaMenu] = useState<MegaMenuPayload[]>([])
|
||||
const [menuLoaded, setMenuLoaded] = useState(false)
|
||||
// Seed from server-fetched initialMenu — no client flash
|
||||
const [megaMenu] = useState<MegaMenuPayload[]>(initialMenu)
|
||||
const navRef = useRef<HTMLElement>(null)
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
@@ -76,10 +77,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
const logout = useAuthStore((state) => state.logout)
|
||||
const cartCount = getCartCount()
|
||||
const { t, i18n } = useTranslation()
|
||||
const lang = i18n.language ? i18n.language.substring(0, 2) : 'th'
|
||||
const lang = useLang()
|
||||
|
||||
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) => {
|
||||
i18n.changeLanguage(code)
|
||||
@@ -96,30 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
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(() => {
|
||||
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>
|
||||
<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.search.allProducts'), to: '/shop' },
|
||||
].map((item) => (
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import type { Product } from '@homaroma/types'
|
||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
import WishlistButton from './WishlistButton'
|
||||
|
||||
interface ProductCardProps {
|
||||
@@ -9,8 +13,10 @@ interface ProductCardProps {
|
||||
}
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
const categoryName = resolveLocalized(product.category.name)
|
||||
const productName = resolveLocalized(product.name) || product.scent.name
|
||||
const { i18n } = useTranslation()
|
||||
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 firstPrice = product.variants[0]?.price ?? product.price
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
"use client"
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
|
||||
type DynamicPageRendererProps = {
|
||||
sections: PageSection[]
|
||||
categories: Category[]
|
||||
lang?: string
|
||||
lang?: string // optional override; defaults to i18n.language
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex w-full flex-col">
|
||||
{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 } }) ?? []
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
|
||||
@@ -95,15 +95,15 @@ export async function getOrderResult(id: string | number): Promise<ApiResult<Ord
|
||||
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 } })
|
||||
}
|
||||
|
||||
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 } })
|
||||
}
|
||||
|
||||
export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
||||
export async function getProducts(query = '', lang = 'all'): Promise<Product[]> {
|
||||
const separator = query ? '&' : ''
|
||||
const response = await fetchJson<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
next: { revalidate: defaultRevalidate },
|
||||
@@ -112,7 +112,7 @@ export async function getProducts(query = '', lang = 'th'): Promise<Product[]> {
|
||||
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 response = await fetchJsonResult<ProductListResponse>(`/products?${query}${separator}lang=${lang}`, {
|
||||
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