fix update Section Block

This commit is contained in:
Your Name committed 2026-06-17 21:08:24 -04:00
1 parent 996dfdabd0
commit 6f67ee6077
47 files changed
+1776 -181

No files matched your search

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