This commit is contained in:
Your Name committed 2026-06-13 23:10:33 -04:00
1 parent 684fd6907b
commit 310e48fe6a
17 files changed
+424 -139

No files matched your search

+46 -20
View File
@@ -33,9 +33,13 @@ export default function ProductEditPage() {
const [success, setSuccess] = useState(false)
// Form State
const [activeLang, setActiveLang] = useState('th')
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko']
const [categoryId, setCategoryId] = useState<number>(0)
const [description, setDescription] = useState('')
const [details, setDetails] = useState('')
const [name, setName] = useState<Record<string, string>>({})
const [description, setDescription] = useState<Record<string, string>>({})
const [details, setDetails] = useState<Record<string, string>>({})
const [variants, setVariants] = useState<Partial<Variant>[]>([])
const [images, setImages] = useState<Partial<ProductImage>[]>([])
@@ -101,7 +105,7 @@ export default function ProductEditPage() {
const fetchData = async () => {
try {
const [prodRes, catRes, rawRes] = await Promise.all([
fetch(`${API_BASE}/products/${id}`),
fetch(`${API_BASE}/products/${id}?lang=all`),
fetch(`${API_BASE}/categories`),
fetch(`${API_BASE}/raw-materials`)
])
@@ -116,8 +120,12 @@ export default function ProductEditPage() {
setCategories(catData)
setRawMaterials(rawData)
setCategoryId(prodData.categoryId)
setDescription(prodData.description || '')
setDetails(prodData.details || '')
const parseTranslatable = (val: any) => typeof val === 'object' && val !== null ? val : { th: val || '' }
setName(parseTranslatable(prodData.name))
setDescription(parseTranslatable(prodData.description))
setDetails(parseTranslatable(prodData.details))
setVariants(prodData.variants || [])
setImages(prodData.images || [])
} catch (err: any) {
@@ -221,6 +229,7 @@ export default function ProductEditPage() {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
categoryId,
name,
description,
details,
variants: variants.map(v => ({
@@ -324,18 +333,35 @@ export default function ProductEditPage() {
<div className="lg:col-span-2 space-y-8">
{/* Basic Info */}
<section className="admin-card space-y-6">
<div className="flex items-center gap-2 text-cyan-glow">
<Info className="w-5 h-5" />
<h2 className="font-bold text-lg">Basic Information</h2>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-cyan-glow">
<Info className="w-5 h-5" />
<h2 className="font-bold text-lg">Basic Information</h2>
</div>
<div className="flex bg-white/5 rounded-lg p-1">
{languages.map(lang => (
<button
key={lang}
onClick={(e) => { e.preventDefault(); setActiveLang(lang); }}
className={`px-4 py-1.5 rounded-md text-xs font-bold uppercase tracking-widest transition-all ${activeLang === lang ? 'bg-cyan-glow text-night-950 shadow-lg' : 'text-slate-400 hover:text-white'}`}
>
{lang}
</button>
))}
</div>
</div>
<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-500 ml-1">Scent Name</label>
<div className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-xl text-slate-400 text-sm italic">
{product?.scent.name} (ReadOnly)
</div>
<p className="text-[10px] text-slate-600 px-1 mt-1">Scent properties are linked to the global library.</p>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Product Name ({activeLang.toUpperCase()})</label>
<input
type="text"
value={name[activeLang] || ''}
onChange={e => setName({ ...name, [activeLang]: e.target.value })}
placeholder={`e.g. Scented Candle (${activeLang})`}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all shadow-inner"
/>
<p className="text-[10px] text-slate-600 px-1 mt-1">Scent Name: <span className="italic">{product?.scent.name}</span></p>
</div>
<div className="space-y-2">
@@ -352,22 +378,22 @@ export default function ProductEditPage() {
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description</label>
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500 ml-1">Short Description ({activeLang.toUpperCase()})</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
value={description[activeLang] || ''}
onChange={e => setDescription({ ...description, [activeLang]: e.target.value })}
className="w-full px-4 py-3 bg-night-950 border border-white/10 focus:border-cyan-glow/40 rounded-xl outline-none text-sm text-white transition-all min-h-[80px]"
placeholder="A brief summary of the product (shows under the title)."
placeholder={`A brief summary of the product in ${activeLang}...`}
/>
</div>
<div className="md:col-span-2 space-y-2 prose-editor-container bg-white text-black rounded-lg overflow-hidden">
<div className="bg-night-950 text-slate-400 p-2 text-[10px] uppercase font-bold tracking-widest px-4 border-b border-white/10">Full Details</div>
<div className="bg-night-950 text-slate-400 p-2 text-[10px] uppercase font-bold tracking-widest px-4 border-b border-white/10">Full Details ({activeLang.toUpperCase()})</div>
<ReactQuill
ref={quillRef}
theme="snow"
value={details}
onChange={setDetails}
value={details[activeLang] || ''}
onChange={val => setDetails({ ...details, [activeLang]: val })}
modules={modules}
className="min-h-[300px]"
/>
+1 -1
View File
@@ -313,7 +313,7 @@ export default function ProductsPage() {
/>
</div>
<div className="overflow-hidden">
<p className="text-sm font-bold text-white truncate">{product.scent.name}</p>
<p className="text-sm font-bold text-white truncate">{typeof product.name === 'string' ? product.name : (product.name?.th || product.scent?.name)}</p>
<p className="text-[10px] uppercase font-bold text-slate-600 tracking-wider">PID: {product.id.toString().padStart(4, '0')}</p>
</div>
</div>
+15 -3
View File
@@ -28,9 +28,6 @@ model Category {
model Product {
id Int @id @default(autoincrement())
name String
description String?
details String?
price Float? @default(0)
categoryId Int
category Category @relation(fields: [categoryId], references: [id])
@@ -39,12 +36,27 @@ model Product {
variants ProductVariant[]
images ProductImage[]
reviews Review[]
translations ProductTranslation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([scentId, categoryId])
}
model ProductTranslation {
id Int @id @default(autoincrement())
productId Int
product Product @relation(fields: [productId], references: [id], onDelete: Cascade)
langCode String
name String
description String?
details String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([productId, langCode])
}
model ProductImage {
id Int @id @default(autoincrement())
url String
+45
View File
@@ -0,0 +1,45 @@
import prisma from '../src/lib/prisma';
async function main() {
console.log('Starting i18n migration...');
const products = await prisma.product.findMany();
console.log(`Found ${products.length} products to migrate.`);
for (const product of products) {
const existingTranslation = await prisma.productTranslation.findUnique({
where: {
productId_langCode: {
productId: product.id,
langCode: 'th',
},
},
});
if (!existingTranslation) {
await prisma.productTranslation.create({
data: {
productId: product.id,
langCode: 'th',
name: product.name,
description: product.description,
details: product.details,
},
});
console.log(`Created Thai translation for product ID: ${product.id}`);
} else {
console.log(`Thai translation already exists for product ID: ${product.id}`);
}
}
console.log('Migration completed successfully.');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+20 -3
View File
@@ -15,6 +15,7 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
})
.get("/:id/products", async ({ params, query, status }) => {
const id = parseInt(params.id);
const lang = (query as any).lang || 'th';
if (isNaN(id)) return status(400, { message: "Invalid category ID" });
// Pagination params
@@ -30,8 +31,9 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
include: {
scent: true,
category: true,
variants: { orderBy: { price: "asc" } },
images: { orderBy: { order: "asc" } },
translations: true,
variants: { orderBy: { price: "asc" as const } },
images: { orderBy: { order: "asc" as const } },
},
orderBy: { name: "asc" },
skip,
@@ -54,7 +56,22 @@ export const categoriesRoute = new Elysia({ prefix: "/categories" })
imageUrl: category.imageUrl,
_count: category._count,
},
data: category.products,
data: category.products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations?.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...p, name: nameObj, description: descObj, details: detailsObj };
} else {
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
}
}),
meta: {
total,
page,
+18 -1
View File
@@ -28,6 +28,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
})
.get("/:slug", async ({ params: { slug }, query, set }) => {
const includeInactive = String((query as any)?.includeInactive || "").toLowerCase() === "true";
const lang = (query as any)?.lang || 'th';
const page = await prisma.page.findUnique({
where: { slug },
include: {
@@ -56,6 +57,7 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const productInclude = {
category: true,
scent: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
@@ -88,7 +90,22 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
products.sort((a, b) => ids.indexOf(a.id) - ids.indexOf(b.id));
}
populatedPayload.products = products;
populatedPayload.products = products.map((p: any) => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations?.forEach((t: any) => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...p, name: nameObj, description: descObj, details: detailsObj };
} else {
const trans = p.translations?.find((t: any) => t.langCode === lang) || p.translations?.find((t: any) => t.langCode === 'th') || p.translations?.[0];
return { ...p, name: trans?.name || p.scent?.name || '', description: trans?.description, details: trans?.details };
}
});
} else if (section.type === 'CATEGORIES') {
let categories: any[] = [];
if (Array.isArray(payload.categoryIds) && payload.categoryIds.length > 0) {
+103 -49
View File
@@ -11,6 +11,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const categoryId = query.categoryId ? parseInt(query.categoryId) : undefined;
const scentId = query.scentId ? parseInt(query.scentId) : undefined;
const search = query.search?.trim() || undefined;
const lang = query.lang || 'th';
// Pagination params
const page = query.page ? parseInt(query.page) : 1;
@@ -22,7 +23,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
...(scentId && { scentId }),
...(search && {
OR: [
{ name: { contains: search, mode: "insensitive" as const } },
{ translations: { some: { name: { contains: search, mode: "insensitive" as const } } } },
{ scent: { name: { contains: search, mode: "insensitive" as const } } },
{ category: { name: { contains: search, mode: "insensitive" as const } } },
],
@@ -35,6 +36,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
include: {
category: true,
scent: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
@@ -43,20 +45,38 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
images: { orderBy: { order: "asc" } },
},
orderBy: { name: "asc" },
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
console.log(`Fetch products paginated: count=${products.length}, total=${total}`)
if (products.length > 0) {
console.log(`Sample product ${products[0].id} images count: ${products[0].images?.length || 0}`)
}
// Map translations
const mappedProducts = products.map(p => {
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
p.translations.forEach(t => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...p, name: nameObj, description: descObj, details: detailsObj };
} else {
const trans = p.translations.find(t => t.langCode === lang) || p.translations.find(t => t.langCode === 'th') || p.translations[0];
return { ...p, name: trans?.name || '', description: trans?.description, details: trans?.details };
}
});
mappedProducts.sort((a, b) => {
const aName = typeof a.name === 'string' ? a.name : (a.name?.th || '');
const bName = typeof b.name === 'string' ? b.name : (b.name?.th || '');
return aName.localeCompare(bName);
});
return {
data: products,
data: mappedProducts,
meta: {
total,
page,
@@ -65,15 +85,17 @@ export const productsRoute = new Elysia({ prefix: "/products" })
},
};
})
.get("/:id", async ({ params, status }) => {
.get("/:id", async ({ params, query, status }) => {
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const lang = query.lang || 'th';
const product = await prisma.product.findUnique({
where: { id },
include: {
category: true,
scent: true,
translations: true,
variants: {
include: {
bomItems: { include: { rawMaterial: true } }
@@ -85,7 +107,21 @@ export const productsRoute = new Elysia({ prefix: "/products" })
});
if (!product) return status(404, { message: "Product not found" });
return product;
if (lang === 'all') {
const nameObj: any = {};
const descObj: any = {};
const detailsObj: any = {};
product.translations.forEach(t => {
nameObj[t.langCode] = t.name;
descObj[t.langCode] = t.description;
detailsObj[t.langCode] = t.details;
});
return { ...product, name: nameObj, description: descObj, details: detailsObj };
} else {
const trans = product.translations.find(t => t.langCode === lang) || product.translations.find(t => t.langCode === 'th') || product.translations[0];
return { ...product, name: trans?.name || '', description: trans?.description, details: trans?.details };
}
})
.put(
"/:id",
@@ -93,15 +129,14 @@ export const productsRoute = new Elysia({ prefix: "/products" })
const id = parseInt(params.id);
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
const { categoryId, description, details, variants, images } = body;
console.log(`[Product Update] ID=${id}, Category=${categoryId}, Variants=${variants?.length || 0}, Images=${images?.length || 0}`);
const { categoryId, description, details, variants, images } = body as any;
try {
const updatedProduct = await prisma.$transaction(async (tx) => {
// 1. Update basic product info
const prod = await tx.product.update({
where: { id },
data: { categoryId, description, details },
data: { categoryId },
});
// 2. Sync Variants
@@ -133,8 +168,6 @@ export const productsRoute = new Elysia({ prefix: "/products" })
});
if (images && images.length > 0) {
// Using a loop instead of createMany to be more robust with Postgres sequence/ID management
// and avoid unique constraint collisions if IDs were somehow kept in body
for (const [idx, img] of images.entries()) {
await tx.productImage.create({
data: {
@@ -156,33 +189,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
},
{
body: t.Object({
categoryId: t.Number(),
description: t.Optional(t.String()),
details: t.Optional(t.String()),
variants: t.Array(
t.Object({
size: t.String(),
price: t.Number(),
bomItems: t.Optional(
t.Array(
t.Object({
rawMaterialId: t.Number(),
quantity: t.Number(),
})
)
),
})
),
images: t.Optional(
t.Array(
t.Object({
url: t.String(),
order: t.Optional(t.Number()),
})
)
),
}),
body: t.Any(),
detail: {
summary: "Update Product",
tags: ["products"],
@@ -196,10 +203,63 @@ export const productsRoute = new Elysia({ prefix: "/products" })
if (isNaN(id)) return status(400, { message: "Invalid product ID" });
try {
const payload = body as any;
const prodData: any = {};
if (payload.categoryId !== undefined) prodData.categoryId = payload.categoryId;
if (payload.scentId !== undefined) prodData.scentId = payload.scentId;
if (payload.price !== undefined) prodData.price = payload.price;
const prod = await prisma.product.update({
where: { id },
data: body,
data: prodData,
});
// Update translations
// If name, description, details are strings, treat as 'th'
// If they are objects, iterate over them
const languages = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
for (const lang of languages) {
const updateData: any = {};
let shouldUpdate = false;
if (payload.name) {
const val = typeof payload.name === 'string' ? (lang === 'th' ? payload.name : null) : payload.name[lang];
if (val !== undefined && val !== null) { updateData.name = val; shouldUpdate = true; }
}
if (payload.description) {
const val = typeof payload.description === 'string' ? (lang === 'th' ? payload.description : null) : payload.description[lang];
if (val !== undefined && val !== null) { updateData.description = val; shouldUpdate = true; }
}
if (payload.details) {
const val = typeof payload.details === 'string' ? (lang === 'th' ? payload.details : null) : payload.details[lang];
if (val !== undefined && val !== null) { updateData.details = val; shouldUpdate = true; }
}
if (shouldUpdate) {
// First check if translation exists to create or update
const existing = await prisma.productTranslation.findUnique({
where: { productId_langCode: { productId: id, langCode: lang } }
});
if (existing) {
await prisma.productTranslation.update({
where: { id: existing.id },
data: updateData
});
} else if (updateData.name) { // name is required for creation
await prisma.productTranslation.create({
data: {
productId: id,
langCode: lang,
name: updateData.name,
description: updateData.description,
details: updateData.details
}
});
}
}
}
return { success: true, product: prod };
} catch (err: any) {
console.error("Patch product error:", err);
@@ -207,13 +267,7 @@ export const productsRoute = new Elysia({ prefix: "/products" })
}
},
{
body: t.Object({
name: t.Optional(t.String()),
categoryId: t.Optional(t.Number()),
scentId: t.Optional(t.Nullable(t.Number())),
description: t.Optional(t.String()),
details: t.Optional(t.String()),
}),
body: t.Any(),
detail: {
summary: "Partial Update Product",
tags: ["products"],
+32 -4
View File
@@ -70,7 +70,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
type: "object",
properties: {
search: { type: "string", description: "Search term" },
limit: { type: "number", description: "Max results" }
limit: { type: "number", description: "Max results" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
}
}
},
@@ -80,7 +81,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Product ID" }
id: { type: "number", description: "Product ID" },
lang: { type: "string", description: "Language code (e.g. th, en, all)" }
},
required: ["id"]
}
@@ -99,6 +101,21 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
required: ["id"]
}
},
{
name: "update_product_translation",
description: "Update product translation for a specific language. Use this to add missing translations.",
inputSchema: {
type: "object",
properties: {
id: { type: "number", description: "Product ID" },
lang: { type: "string", description: "Language code (e.g., en, ja, zh, vi, ko)" },
name: { type: "string", description: "Translated product name" },
description: { type: "string", description: "Translated description" },
details: { type: "string", description: "Translated details (HTML or Markdown allowed)" }
},
required: ["id", "lang"]
}
},
{
name: "upload_product_image_from_url",
description: "Download image from URL, upload to backend, and append to product images",
@@ -325,11 +342,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
case "search_products": {
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
const res = await api.get(`/products?${search}${limit}`);
const lang = params.lang ? `&lang=${params.lang}` : "";
const res = await api.get(`/products?${search}${limit}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "get_product_detail": {
const res = await api.get(`/products/${params.id}`);
const lang = params.lang ? `?lang=${params.lang}` : "";
const res = await api.get(`/products/${params.id}${lang}`);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_product_content": {
@@ -341,6 +360,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
const res = await api.patch(`/products/${params.id}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "update_product_translation": {
const payload: any = {};
if (params.name) payload.name = { [params.lang]: params.name };
if (params.description) payload.description = { [params.lang]: params.description };
if (params.details) payload.details = { [params.lang]: params.details };
const res = await api.patch(`/products/${params.id}`, payload);
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
}
case "upload_product_image_from_url": {
// 1. Download image
const imgRes = await axios.get(params.imageUrl, { responseType: 'arraybuffer' });
+89 -20
View File
@@ -1,7 +1,7 @@
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import { API_BASE, type Category, type MenuItem } from '../types'
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart } from 'lucide-react'
import { ShoppingBag, MapPin, Menu as MenuIcon, X, Heart, Globe, Check } from 'lucide-react'
import { useCartStore } from '../store/cartStore'
import { useWishlistStore } from '../store/wishlistStore'
import { useAuthStore } from '../store/authStore'
@@ -16,6 +16,7 @@ interface NavbarProps {
export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }: NavbarProps) {
const [searchOpen, setSearchOpen] = useState(false)
const [mobileOpen, setMobileOpen] = useState(false)
const [langSheetOpen, setLangSheetOpen] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [scrolled, setScrolled] = useState(false)
const [localOrdersCount, setLocalOrdersCount] = useState(0)
@@ -29,12 +30,20 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
const { customer } = useAuthStore()
const { t, i18n } = useTranslation()
const toggleLanguage = () => {
const newLang = i18n.language.startsWith('en') ? 'th' : 'en'
i18n.changeLanguage(newLang)
localStorage.setItem('homaroma_lang', newLang)
}
const LANGUAGES = [
{ code: 'th', label: 'ไทย', short: 'TH' },
{ code: 'en', label: 'English', short: 'EN' },
{ code: 'ja', label: '日本語', short: 'JP' },
{ code: 'zh', label: '简体中文', short: 'ZH' },
{ code: 'vi', label: 'Tiếng Việt', short: 'VN' },
{ code: 'ko', label: '한국어', short: 'KO' },
]
const changeLanguage = (code: string) => {
i18n.changeLanguage(code)
localStorage.setItem('homaroma_lang', code)
setLangSheetOpen(false)
}
// Detect scroll and announcement bar presence
useEffect(() => {
const checkAnnouncement = () => {
@@ -80,9 +89,9 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
useEffect(() => { setMobileOpen(false) }, [navigate])
// Prevent body scroll when overlays open
useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
document.body.style.overflow = (searchOpen || mobileOpen || langSheetOpen) ? 'hidden' : ''
return () => { document.body.style.overflow = '' }
}, [searchOpen, mobileOpen])
}, [searchOpen, mobileOpen, langSheetOpen])
const handleSearch = (e: React.FormEvent) => {
e.preventDefault()
@@ -141,8 +150,8 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 w-52 bg-white/95 backdrop-blur-xl border border-black/5 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
<div className="p-2 space-y-0.5">
<div className="absolute top-full left-1/2 -translate-x-1/2 pt-4 w-52 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-white/95 backdrop-blur-xl border border-black/5 rounded-xl overflow-hidden shadow-2xl p-2 space-y-0.5">
{categories.map((cat) => (
<Link
key={cat.id}
@@ -224,12 +233,29 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
{t('nav.shopNow')}
</Link>
<button
onClick={toggleLanguage}
className="px-3 py-2 rounded-full text-xs font-bold transition-all text-spa-forest border border-spa-forest/30 hover:bg-spa-sage bg-white"
>
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button>
<div className="relative group flex items-center">
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-spa-forest border border-spa-forest/30 hover:bg-spa-sage bg-white flex items-center gap-1.5 uppercase tracking-wider">
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
</button>
<div className="absolute top-full right-0 pt-2 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
{LANGUAGES.map(lang => {
const isActive = i18n.language.startsWith(lang.code)
return (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-spa-forest bg-spa-sage/60 font-bold' : 'text-spa-stone hover:bg-spa-light hover:text-spa-forest'}`}
>
<span>{lang.label}</span>
{isActive && <Check className="w-3.5 h-3.5" />}
</button>
)
})}
</div>
</div>
</div>
{customer ? (
<div className="relative group flex items-center">
@@ -240,8 +266,8 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 cursor-pointer object-cover animate-fade-in"
/>
</Link>
<div className="absolute top-full right-0 mt-4 w-36 bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-xl">
<div className="p-1">
<div className="absolute top-full right-0 pt-3 w-36 opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300">
<div className="bg-white/95 backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">Signed in as</p>
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
@@ -377,8 +403,9 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
{customer && (
<img src={customer.pictureUrl || ''} className="w-6 h-6 rounded-full border-2 border-spa-forest/30" />
)}
<button onClick={toggleLanguage} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-spa-forest border border-spa-forest/30 uppercase bg-spa-sage">
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
<button onClick={() => setLangSheetOpen(true)} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-spa-forest border border-spa-forest/30 uppercase bg-spa-sage flex items-center gap-1 shrink-0">
<Globe className="w-3 h-3" />
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
</button>
<button onClick={() => setMobileOpen(false)} className="p-1.5 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -532,6 +559,48 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
</div>
</div>
)}
{/* ── MOBILE LANGUAGE BOTTOM SHEET ── */}
<div
className={`fixed inset-0 z-[110] md:hidden transition-all duration-300 ${langSheetOpen ? 'visible' : 'invisible'}`}
>
<div
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${langSheetOpen ? 'opacity-100' : 'opacity-0'}`}
onClick={() => setLangSheetOpen(false)}
/>
<div
className={`absolute bottom-0 left-0 right-0 bg-white rounded-t-3xl transition-transform duration-300 shadow-2xl pb-safe ${langSheetOpen ? 'translate-y-0' : 'translate-y-full'}`}
>
<div className="flex justify-center pt-3 pb-1">
<div className="w-12 h-1.5 bg-spa-stone/20 rounded-full" />
</div>
<div className="px-6 pb-6 pt-2">
<div className="flex justify-between items-center mb-4">
<h3 className="text-sm font-bold uppercase tracking-widest text-spa-forest flex items-center gap-2">
<Globe className="w-4 h-4" />
Select Language
</h3>
<button onClick={() => setLangSheetOpen(false)} className="p-2 rounded-full hover:bg-spa-sage text-spa-stone">
<X className="w-4 h-4" />
</button>
</div>
<div className="space-y-1">
{LANGUAGES.map(lang => {
const isActive = i18n.language.startsWith(lang.code)
return (
<button
key={lang.code}
onClick={() => changeLanguage(lang.code)}
className={`w-full flex items-center justify-between px-4 py-4 rounded-xl transition-colors font-medium border border-transparent ${isActive ? 'bg-spa-sage border-spa-forest/20 text-spa-forest font-bold' : 'bg-spa-light/50 text-spa-stone hover:bg-spa-light'}`}
>
<span className="text-base">{lang.label}</span>
{isActive && <Check className="w-5 h-5" />}
</button>
)
})}
</div>
</div>
</div>
</div>
</>
)
}
+9 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
@@ -13,6 +14,7 @@ export default function CategoryPage() {
const [category, setCategory] = useState<Category | null>(null)
const [products, setProducts] = useState<Product[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
// Local progressive rendering
const [displayCount, setDisplayCount] = useState(24)
@@ -22,14 +24,14 @@ export default function CategoryPage() {
const [error, setError] = useState<string | null>(null)
useEffect(() => {
fetch(`${API_BASE}/categories`)
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
.then((r) => r.json())
.then(setCategories)
.catch(console.error)
}, [])
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/category-layout`)
fetch(`${API_BASE}/pages/category-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
@@ -42,7 +44,7 @@ export default function CategoryPage() {
.catch((err) => {
console.log('Category dynamic layout sections not loaded:', err.message);
});
}, [])
}, [i18n.language])
// Reset state when category changes
useEffect(() => {
@@ -57,8 +59,8 @@ export default function CategoryPage() {
Promise.all([
// Fetch up to 1000 items (essentially all) in one go
fetch(`${API_BASE}/categories/${id}/products?limit=1000`),
fetch(`${API_BASE}/categories`),
fetch(`${API_BASE}/categories/${id}/products?limit=1000&lang=${i18n.language}`),
fetch(`${API_BASE}/categories?lang=${i18n.language}`),
])
.then(async ([catRes, allCatsRes]) => {
if (!catRes.ok) throw new Error('Category not found')
@@ -71,7 +73,7 @@ export default function CategoryPage() {
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id])
}, [id, i18n.language])
// Intersection Observer for infinite scroll
useEffect(() => {
+5 -3
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import Navbar from '../components/Navbar';
import Footer from '../components/Footer';
import DynamicPageRenderer from '../components/DynamicPageRenderer';
@@ -13,6 +14,7 @@ export default function CustomPage() {
const [loading, setLoading] = useState(true);
const [categoriesLoading, setCategoriesLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const { i18n } = useTranslation();
const announcementSections = sections.filter(
(section) => section.isActive && section.type.toUpperCase() === 'ANNOUNCEMENT_BAR'
);
@@ -24,7 +26,7 @@ export default function CustomPage() {
useEffect(() => {
const fetchCategories = async () => {
try {
const res = await fetch(`${API_BASE}/categories`);
const res = await fetch(`${API_BASE}/categories?lang=${i18n.language}`);
const cats: Category[] = await res.json();
setCategories(cats);
} catch (err) {
@@ -39,7 +41,7 @@ export default function CustomPage() {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/pages/${slug}`);
const res = await fetch(`${API_BASE}/pages/${slug}?lang=${i18n.language}`);
if (!res.ok) {
setError('Page not found');
return;
@@ -60,7 +62,7 @@ export default function CustomPage() {
fetchCategories();
fetchPageLayout();
}, [slug]);
}, [slug, i18n.language]);
return (
<div className="min-h-screen bg-[#FDFDF9] text-[#1F3D2D]">
+5 -3
View File
@@ -1,4 +1,5 @@
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
@@ -9,11 +10,12 @@ export default function HomePage() {
const [sections, setSections] = useState<PageSection[]>([])
const [loading, setLoading] = useState(true)
const [categoriesLoading, setCategoriesLoading] = useState(true)
const { i18n } = useTranslation()
useEffect(() => {
const fetchCategories = async () => {
try {
const res = await fetch(`${API_BASE}/categories`)
const res = await fetch(`${API_BASE}/categories?lang=${i18n.language}`)
const cats: Category[] = await res.json()
setCategories(cats)
} catch (err) {
@@ -25,7 +27,7 @@ export default function HomePage() {
const fetchPageLayout = async () => {
try {
const res = await fetch(`${API_BASE}/pages/home`)
const res = await fetch(`${API_BASE}/pages/home?lang=${i18n.language}`)
const pageData = await res.json()
if (pageData && pageData.sections) {
setSections(pageData.sections)
@@ -39,7 +41,7 @@ export default function HomePage() {
fetchCategories()
fetchPageLayout()
}, [])
}, [i18n.language])
return (
<div className="min-h-screen bg-pop-bg">
+9 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { useParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
@@ -21,16 +22,17 @@ export default function ProductDetailPage() {
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { addToCart } = useCartStore()
const { i18n } = useTranslation()
useEffect(() => {
fetch(`${API_BASE}/categories`)
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
.then((r) => r.json())
.then(setCategories)
.catch(console.error)
}, [])
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/product-detail-layout`)
fetch(`${API_BASE}/pages/product-detail-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
@@ -43,7 +45,7 @@ export default function ProductDetailPage() {
.catch((err) => {
console.log('Product detail dynamic layout sections not loaded:', err.message);
});
}, [])
}, [i18n.language])
useEffect(() => {
if (!id) return
@@ -51,7 +53,7 @@ export default function ProductDetailPage() {
setError(null)
setSelectedVariant(0)
fetch(`${API_BASE}/products/${id}`)
fetch(`${API_BASE}/products/${id}?lang=${i18n.language}`)
.then(async (r) => {
if (!r.ok) throw new Error('Product not found')
return r.json()
@@ -59,14 +61,14 @@ export default function ProductDetailPage() {
.then(async (data: Product) => {
setProduct(data)
// Fetch related (same category, different product)
const relRes = await fetch(`${API_BASE}/products?categoryId=${data.category.id}`)
const relRes = await fetch(`${API_BASE}/products?categoryId=${data.category.id}&lang=${i18n.language}`)
const relJson = await relRes.json()
const relData: Product[] = relJson.data || []
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [id])
}, [id, i18n.language])
if (loading) {
return (
+6 -4
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import DynamicPageRenderer from '../components/DynamicPageRenderer'
@@ -9,14 +10,15 @@ export default function ScentGuidePage() {
const [categories, setCategories] = useState<Category[]>([])
const [articles, setArticles] = useState<Article[]>([])
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
fetch(`${API_BASE}/categories?lang=${i18n.language}`).then(r => r.json()).then(setCategories).catch(console.error)
fetch(`${API_BASE}/articles?publishedOnly=true`).then(r => r.json()).then(setArticles).catch(console.error)
}, [])
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/scent-guide-layout`)
fetch(`${API_BASE}/pages/scent-guide-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
@@ -29,7 +31,7 @@ export default function ScentGuidePage() {
.catch((err) => {
console.log('Scent guide dynamic layout sections not loaded:', err.message);
});
}, [])
}, [i18n.language])
const announcementSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() === 'ANNOUNCEMENT_BAR');
const heroSections = cmsSections.filter(s => s.isActive && (s.type.toUpperCase() === 'HERO' || s.type.toUpperCase() === 'BANNER'));
+8 -6
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback } from 'react'
import { useSearchParams, Link } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
@@ -17,13 +18,14 @@ export default function SearchPage() {
const [loading, setLoading] = useState(false)
const [searched, setSearched] = useState(false)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
useEffect(() => {
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
}, [])
fetch(`${API_BASE}/categories?lang=${i18n.language}`).then(r => r.json()).then(setCategories).catch(console.error)
}, [i18n.language])
useEffect(() => {
fetch(`${API_BASE}/pages/search-layout`)
fetch(`${API_BASE}/pages/search-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
@@ -36,14 +38,14 @@ export default function SearchPage() {
.catch((err) => {
console.log('Search dynamic layout sections not loaded:', err.message);
});
}, [])
}, [i18n.language])
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) { setProducts([]); setSearched(false); return }
setLoading(true)
setSearched(true)
try {
const res = await fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}`)
const res = await fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}&lang=${i18n.language}`)
const paginated = await res.json()
setProducts(paginated.data || [])
} catch (err) {
@@ -51,7 +53,7 @@ export default function SearchPage() {
} finally {
setLoading(false)
}
}, [])
}, [i18n.language])
// Search when URL query changes
useEffect(() => {
+8 -5
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react'
import { Link, useLocation } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import ProductCard from '../components/ProductCard'
@@ -24,6 +25,7 @@ export default function ShopPage() {
const [loading, setLoading] = useState(true)
const [productsLoading, setProductsLoading] = useState(false)
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
const { i18n } = useTranslation()
// Handle coupon from URL
useEffect(() => {
@@ -38,18 +40,18 @@ export default function ShopPage() {
// Fetch categories
useEffect(() => {
fetch(`${API_BASE}/categories`)
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
.then((r) => r.json())
.then((data: Category[]) => {
setCategories(data)
setLoading(false)
})
.catch(console.error)
}, [])
}, [i18n.language])
// Fetch CMS sections for shop-layout
useEffect(() => {
fetch(`${API_BASE}/pages/shop-layout`)
fetch(`${API_BASE}/pages/shop-layout?lang=${i18n.language}`)
.then((r) => {
if (!r.ok) throw new Error('Page not found');
return r.json();
@@ -62,7 +64,7 @@ export default function ShopPage() {
.catch((err) => {
console.log('Shop dynamic layout sections not loaded:', err.message);
});
}, [])
}, [i18n.language])
// Debounce search input
useEffect(() => {
@@ -86,6 +88,7 @@ export default function ShopPage() {
params.append('limit', '1000') // fetch all matching items locally
if (activeCategory) params.append('categoryId', activeCategory.toString())
if (searchQuery.trim()) params.append('search', searchQuery.trim())
params.append('lang', i18n.language)
fetch(`${API_BASE}/products?${params.toString()}`)
.then((r) => r.json())
@@ -97,7 +100,7 @@ export default function ShopPage() {
})
.catch(console.error)
.finally(() => setProductsLoading(false))
}, [activeCategory, searchQuery])
}, [activeCategory, searchQuery, i18n.language])
// Intersection Observer for infinite scroll
useEffect(() => {