update lang
This commit is contained in:
1 parent
46f00c76a4
commit
f5b60ff63e
30 files changed
+542
-648
No files matched your search
@@ -62,6 +62,10 @@ description: Main Agent / Multi-Agent Orchestrator ทำหน้าที่
|
||||
> **RULE #9: System Halt Trigger (กฎการหยุดฉุกเฉิน)**
|
||||
> หากคุณพยายามเรียกใช้ MCP Tool แล้วพบว่าผลลัพธ์ตีกลับมาเป็นว่างเปล่า (`[]`) หรือเกิด Error ที่ผิดปกติ (โดยเฉพาะหลังจากเพิ่งแก้ไขโค้ด MCP Server) **ห้ามคิดพยายามแก้ปัญหาด้วยการสร้างทางลัดหรือรันสคริปต์แก้ไขเด็ดขาด!** ให้คุณหยุดทำงานทันที และพิมพ์คำสั่ง **`[SYSTEM HALT: REQUIRE MCP RESTART]`** ลงในคำตอบ เพื่อแจ้งให้ User ทราบทันทีว่ากระบวนการ MCP Server ค้างอยู่และต้องการการรีสตาร์ทเพื่อโหลด Schema ใหม่
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **RULE #10: ห้ามเขียนหรือแก้ไขโค้ดเองเด็ดขาด (Strict Code Delegation)**
|
||||
> หากมีงานที่เกี่ยวข้องกับการเขียน แก้ไข หรือลบโค้ด ห้ามคุณเข้าไปยุ่งกับโค้ดด้วยตัวเองเด็ดขาด! คุณต้องมีหน้าที่เพียงวางแผนและเรียกใช้ (Invoke) Sub Agent ที่ตรงสายงาน (เช่น `frontend-lead` หรือ `backend-mcp-lead`) ให้ทำงานแทนเสมอ หน้าที่ของคุณคือการจ่ายงานและตรวจสอบความเรียบร้อยของผลลัพธ์เท่านั้น
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Task Delegation Workflow (ขั้นตอนการทำงาน)
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function MenuManagerPage() {
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4 border-b border-white/5 pb-4">
|
||||
{['main', 'footer-explore'].map(name => (
|
||||
{['main', 'footer-explore', 'footer-support'].map(name => (
|
||||
<button
|
||||
key={name}
|
||||
onClick={() => setSelectedMenuName(name)}
|
||||
@@ -89,7 +89,7 @@ export default function MenuManagerPage() {
|
||||
selectedMenuName === name ? 'bg-cyan-glow text-night-900' : 'bg-white/5 text-slate-400 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{name === 'main' ? 'Main Navigation' : 'Footer Explore'}
|
||||
{name === 'main' ? 'Main Navigation' : name === 'footer-explore' ? 'Footer Explore' : 'Footer Support'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -117,26 +117,59 @@ export default function MenuManagerPage() {
|
||||
<div key={item.id} className="flex gap-4 items-center bg-white/5 p-4 rounded-xl border border-white/5 group">
|
||||
<GripVertical className="w-5 h-5 text-slate-600 cursor-move" />
|
||||
|
||||
<div className="flex-1 grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={e => handleUpdateItem(idx, 'label', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Shop All"
|
||||
/>
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Label (EN)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.label}
|
||||
onChange={e => handleUpdateItem(idx, 'label', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. Shop All"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">URL path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.url}
|
||||
onChange={e => handleUpdateItem(idx, 'url', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. /shop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">URL path</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item.url}
|
||||
onChange={e => handleUpdateItem(idx, 'url', e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="e.g. /shop"
|
||||
/>
|
||||
|
||||
{/* Translations */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold text-slate-500">Translations</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{['th', 'ja', 'zh', 'vi', 'ko'].map(lang => {
|
||||
const transVal = item.translations?.find((t: any) => t.langCode === lang)?.label || ''
|
||||
return (
|
||||
<div key={lang} className="flex items-center gap-2">
|
||||
<span className="text-[10px] font-bold text-slate-400 uppercase w-4">{lang}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={transVal}
|
||||
onChange={e => {
|
||||
const newTranslations = [...(item.translations || [])]
|
||||
const tIdx = newTranslations.findIndex((t: any) => t.langCode === lang)
|
||||
if (tIdx >= 0) {
|
||||
newTranslations[tIdx] = { ...newTranslations[tIdx], label: e.target.value }
|
||||
} else {
|
||||
newTranslations.push({ langCode: lang, label: e.target.value })
|
||||
}
|
||||
handleUpdateItem(idx, 'translations', newTranslations as any)
|
||||
}}
|
||||
className="admin-input w-full text-xs py-1 px-2"
|
||||
placeholder={`Label in ${lang}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ export default function SystemSettingsPage() {
|
||||
const [newsletterPlaceholder, setNewsletterPlaceholder] = useState('')
|
||||
const [newsletterButtonText, setNewsletterButtonText] = useState('')
|
||||
|
||||
const [footerSupportHeading, setFooterSupportHeading] = useState('')
|
||||
const [footerCopyrightText, setFooterCopyrightText] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(r => r.json())
|
||||
@@ -39,6 +42,8 @@ export default function SystemSettingsPage() {
|
||||
setNewsletterDescription(data.newsletterDescription || '')
|
||||
setNewsletterPlaceholder(data.newsletterPlaceholder || '')
|
||||
setNewsletterButtonText(data.newsletterButtonText || '')
|
||||
setFooterSupportHeading(data.footerSupportHeading || '')
|
||||
setFooterCopyrightText(data.footerCopyrightText || '')
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
@@ -65,7 +70,9 @@ export default function SystemSettingsPage() {
|
||||
newsletterTitle,
|
||||
newsletterDescription,
|
||||
newsletterPlaceholder,
|
||||
newsletterButtonText
|
||||
newsletterButtonText,
|
||||
footerSupportHeading,
|
||||
footerCopyrightText
|
||||
})
|
||||
})
|
||||
if (res.ok) alert('Settings saved successfully!')
|
||||
@@ -243,6 +250,29 @@ export default function SystemSettingsPage() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6 mt-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Support & Legal Heading</label>
|
||||
<input
|
||||
type="text"
|
||||
value={footerSupportHeading}
|
||||
onChange={e => setFooterSupportHeading(e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="Support & Legal"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Copyright Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={footerCopyrightText}
|
||||
onChange={e => setFooterCopyrightText(e.target.value)}
|
||||
className="admin-input w-full"
|
||||
placeholder="© 2026 HomAroma. All rights reserved."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
const prisma = new PrismaClient()
|
||||
async function main() {
|
||||
const cats = await prisma.category.findMany({
|
||||
include: { translations: true }
|
||||
})
|
||||
console.log(JSON.stringify(cats, null, 2))
|
||||
}
|
||||
main()
|
||||
@@ -220,6 +220,8 @@ model StoreSettings {
|
||||
newsletterDescription String?
|
||||
newsletterPlaceholder String?
|
||||
newsletterButtonText String?
|
||||
footerSupportHeading Json?
|
||||
footerCopyrightText Json?
|
||||
translations StoreSettingsTranslation[]
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
@@ -98,6 +98,29 @@ async function main() {
|
||||
});
|
||||
process.stdout.write('✅ Seeded Admin User (admin / homaroma123).\n');
|
||||
|
||||
// 0.5 Seed Core Menus
|
||||
const coreMenus = [
|
||||
{ name: "main", items: [{ id: 1, label: "Home", url: "/", order: 1 }, { id: 2, label: "Shop", url: "/shop", order: 2 }, { id: 3, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 4, label: "About Us", url: "/about", order: 4 }] },
|
||||
{ name: "footer-explore", items: [{ id: 5, label: "Home", url: "/", order: 1 }, { id: 6, label: "Shop", url: "/shop", order: 2 }, { id: 7, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 8, label: "About Us", url: "/about", order: 4 }] },
|
||||
{ name: "footer-support", items: [{ id: 9, label: "Track Order", url: "/track-order", order: 1 }, { id: 10, label: "FAQ", url: "/faq", order: 2 }, { id: 11, label: "Contact Us", url: "/contact", order: 3 }, { id: 12, label: "Delivery & Returns", url: "/delivery", order: 4 }] }
|
||||
];
|
||||
|
||||
for (const menu of coreMenus) {
|
||||
const m = await prisma.menu.upsert({
|
||||
where: { name: menu.name },
|
||||
update: {},
|
||||
create: { name: menu.name }
|
||||
});
|
||||
for (const item of menu.items) {
|
||||
await prisma.menuItem.upsert({
|
||||
where: { id: item.id },
|
||||
update: { label: item.label, url: item.url, order: item.order, menuId: m.id },
|
||||
create: { id: item.id, label: item.label, url: item.url, order: item.order, menuId: m.id }
|
||||
});
|
||||
}
|
||||
}
|
||||
process.stdout.write('✅ Seeded Core Menus.\n');
|
||||
|
||||
// 1. Seed Scents & Categories
|
||||
const scents = [];
|
||||
for (const name of scentNames) {
|
||||
@@ -215,6 +238,11 @@ async function main() {
|
||||
process.stdout.write(` - Categories: ${categories.length}\n`);
|
||||
process.stdout.write(` - Products: ${productCount}\n`);
|
||||
process.stdout.write(` - Variants: ${variantCount}\n`);
|
||||
// Fix PostgreSQL sequence desync for tables where we hardcoded IDs
|
||||
process.stdout.write('🔄 Resetting sequences for hardcoded ID tables...\n');
|
||||
await prisma.$executeRawUnsafe(`SELECT setval('"MenuItem_id_seq"', COALESCE((SELECT MAX(id) FROM "MenuItem") + 1, 1), false);`);
|
||||
await prisma.$executeRawUnsafe(`SELECT setval('"ProductImage_id_seq"', COALESCE((SELECT MAX(id) FROM "ProductImage") + 1, 1), false);`);
|
||||
process.stdout.write('✅ Sequences reset successfully.\n');
|
||||
}
|
||||
|
||||
main()
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
import prisma from '../src/lib/prisma';
|
||||
|
||||
async function main() {
|
||||
// 1. Create About Page
|
||||
await prisma.page.upsert({
|
||||
where: { slug: 'about' },
|
||||
update: {},
|
||||
create: {
|
||||
slug: 'about',
|
||||
title: 'About Us',
|
||||
isPublished: true,
|
||||
sections: {
|
||||
create: [
|
||||
{
|
||||
type: 'HERO',
|
||||
order: 1,
|
||||
payload: {
|
||||
tagline: 'Our Story',
|
||||
title: 'Born from a passion for stillness',
|
||||
description: 'HomAroma began in 2019 as a one-person studio in Bangkok, crafting small batches of botanical aroma blends for friends and family. What started as a personal journey toward calm became a philosophy — that our environment shapes how we feel, think, and rest.',
|
||||
align: 'center',
|
||||
bgImage: '',
|
||||
overlay: true
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
order: 2,
|
||||
payload: {
|
||||
tagline: 'Stats',
|
||||
title: 'Company Stats',
|
||||
content: '45+ Unique Scents\n5 Product Lines\nFounded 2019\n100% Natural Extracts',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'EDITORIAL_TILE',
|
||||
order: 3,
|
||||
payload: {
|
||||
tagline: 'Our Mission',
|
||||
title: 'Transforming spaces into sanctuaries of peace',
|
||||
content: 'We believe that scent is the fastest pathway to emotional transformation. A single breath of the right blend can dissolve anxiety, sharpen focus, or unlock creativity. Our mission is to make that transformation accessible — through products that are as beautiful as they are effective.\n\nEvery HomAroma product is designed with intention: the bottles are reusable, the packaging is recycled, and the formulas are written to be understood by anyone — not hidden behind chemical jargon.',
|
||||
imageSrc: '',
|
||||
imageAlt: 'HomAroma Studio, Bangkok',
|
||||
reverse: false
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'TEXT',
|
||||
order: 4,
|
||||
payload: {
|
||||
tagline: 'What We Stand For',
|
||||
title: 'Three Pillars',
|
||||
content: '1. Pure Origins: Every extract is sourced directly from certified organic farms in Northern Thailand, Vietnam, and Southern France. We work exclusively with small-batch artisan growers.\n\n2. Artisan Craft: Each formula is cold-extracted and hand-blended in our Bangkok studio. No heat processing means every aromatic molecule remains intact and therapeutically potent.\n\n3. Zero Compromise: No synthetic fragrance. No parabens. No animal testing. Our commitment to clean formulation is absolute — what touches your senses should honour your wellbeing.',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'BANNER',
|
||||
order: 5,
|
||||
payload: {
|
||||
title: 'Ready to find your scent?',
|
||||
description: 'Let our Scent Guide help you discover the perfect blend for your moment.',
|
||||
buttonText: 'Take the Scent Guide',
|
||||
buttonLink: '/scent-guide'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Create FAQ Page
|
||||
await prisma.page.upsert({
|
||||
where: { slug: 'faq' },
|
||||
update: {},
|
||||
create: {
|
||||
slug: 'faq',
|
||||
title: 'Frequently Asked Questions',
|
||||
isPublished: true,
|
||||
sections: {
|
||||
create: [
|
||||
{
|
||||
type: 'TEXT',
|
||||
order: 1,
|
||||
payload: {
|
||||
tagline: 'Support',
|
||||
title: 'Frequently Asked Questions',
|
||||
content: 'Everything you need to know about HomAroma products, shipping, and care.',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'ACCORDION',
|
||||
order: 2,
|
||||
payload: {
|
||||
categories: [
|
||||
{
|
||||
name: 'Products',
|
||||
icon: '🌿',
|
||||
items: [
|
||||
{ q: 'What are the ingredients in your aroma products?', a: 'All HomAroma products are crafted from 100% natural botanical extracts, pure essential oils, and pharmaceutical-grade carrier bases. We never use synthetic fragrances, parabens, or harmful chemicals. Each product label lists full ingredient transparency.' },
|
||||
{ q: 'How long does each product last?', a: 'Our Reed Diffusers last 4–8 weeks depending on room size and airflow. Aroma Oils (30ml) last approximately 3–4 months with normal use. Room Sprays (100ml) provide 60–80 uses. Car Diffusers cartridges last up to 45 days.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns — we are happy to help identify suitable options.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation, ideal for large rooms and commercial spaces.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Usage',
|
||||
icon: '💡',
|
||||
items: [
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents for longest lasting performance.' },
|
||||
{ q: 'Can I mix different scents?', a: 'Yes! Blending scents is an art form we encourage. A general rule: combine no more than 3 scents at a time, using one dominant (60%), one supporting (30%), and one accent (10%). Our Scent Guide can help you find complementary combinations.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results and to avoid olfactory fatigue, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Shipping & Orders',
|
||||
icon: '📦',
|
||||
items: [
|
||||
{ q: 'How long does shipping take?', a: 'Standard delivery within Bangkok takes 1–2 business days. Provincial orders take 2–4 business days. Express same-day delivery is available in Bangkok for orders placed before 12:00 PM.' },
|
||||
{ q: 'Do you ship internationally?', a: 'Currently we ship within Thailand only. International shipping is planned for late 2025. Sign up for our newsletter to be notified when we expand to your region.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost. Contact us at hello@homaroma.shop with your order number.' },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'Scent Guide',
|
||||
icon: '🧪',
|
||||
items: [
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm, welcoming notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus. Use our interactive Scent Guide for personalized recommendations.' },
|
||||
{ q: 'Can I request custom scent blending?', a: 'Yes! Our bespoke blending service is available for orders of 6 units or more. Contact us at least 10 business days before your desired delivery date. Custom blending fees start at ฿500 per unique formula.' },
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'BANNER',
|
||||
order: 3,
|
||||
payload: {
|
||||
title: 'Still have questions?',
|
||||
description: 'Our team is here to help — usually responds within 2 hours.',
|
||||
buttonText: 'Contact Us',
|
||||
buttonLink: '/contact'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Create Contact Page
|
||||
await prisma.page.upsert({
|
||||
where: { slug: 'contact' },
|
||||
update: {},
|
||||
create: {
|
||||
slug: 'contact',
|
||||
title: 'Contact Us',
|
||||
isPublished: true,
|
||||
sections: {
|
||||
create: [
|
||||
{
|
||||
type: 'TEXT',
|
||||
order: 1,
|
||||
payload: {
|
||||
tagline: 'Get in Touch',
|
||||
title: 'Contact Us',
|
||||
content: 'Have a question, custom request, or partnership idea? We\'d love to hear from you.',
|
||||
align: 'center'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'CONTACT_INFO',
|
||||
order: 2,
|
||||
payload: {
|
||||
title: 'Reach Us Directly',
|
||||
items: [
|
||||
{ icon: '📧', label: 'Email', value: 'hello@homaroma.shop', href: 'mailto:hello@homaroma.shop' },
|
||||
{ icon: '📱', label: 'LINE Official', value: '@homaroma', href: '#' },
|
||||
{ icon: '📸', label: 'Instagram', value: '@homaroma', href: '#' },
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
],
|
||||
responseTime: {
|
||||
title: 'Response Time',
|
||||
email: '~2 hours',
|
||||
line: '~30 minutes',
|
||||
instagram: '~4 hours',
|
||||
hours: 'Mon–Sat, 9:00–18:00 (ICT)'
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'CONTACT_FORM',
|
||||
order: 3,
|
||||
payload: {
|
||||
subjects: ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other'],
|
||||
buttonText: 'Send Message'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Pages created successfully.');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
@@ -7,7 +7,8 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
return await prisma.menu.findMany({
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { order: "asc" }
|
||||
orderBy: { order: "asc" },
|
||||
include: { translations: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -17,19 +18,12 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
where: { name },
|
||||
include: {
|
||||
items: {
|
||||
orderBy: { order: "asc" }
|
||||
orderBy: { order: "asc" },
|
||||
include: { translations: true }
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!menu) {
|
||||
// Return fallback defaults instead of 404 to prevent frontend breakage
|
||||
if (name === "main") {
|
||||
return { name, items: [{ id: 1, label: "Home", url: "/", order: 1 }, { id: 2, label: "Shop", url: "/shop", order: 2 }, { id: 3, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 4, label: "About Us", url: "/about", order: 4 }] };
|
||||
} else if (name === "footer-explore") {
|
||||
return { name, items: [{ id: 5, label: "Home", url: "/", order: 1 }, { id: 6, label: "Shop", url: "/shop", order: 2 }, { id: 7, label: "Scent Guide", url: "/scent-guide", order: 3 }, { id: 8, label: "About Us", url: "/about", order: 4 }] };
|
||||
} else if (name === "footer-support") {
|
||||
return { name, items: [{ id: 9, label: "Track Order", url: "/track-order", order: 1 }, { id: 10, label: "FAQ", url: "/faq", order: 2 }, { id: 11, label: "Contact Us", url: "/contact", order: 3 }, { id: 12, label: "Delivery & Returns", url: "/delivery", order: 4 }] };
|
||||
}
|
||||
return { name, items: [] };
|
||||
}
|
||||
return menu;
|
||||
@@ -65,28 +59,60 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
})
|
||||
.put("/:name", async ({ params: { name }, body, set }) => {
|
||||
try {
|
||||
const { items } = body as { items: { id?: number; label: string; url: string; order: number }[] };
|
||||
const { items } = body as { items: { id?: number; label: string; url: string; order: number; translations?: { langCode: string; label: string }[] }[] };
|
||||
let menu = await prisma.menu.findUnique({ where: { name } });
|
||||
|
||||
if (!menu) {
|
||||
menu = await prisma.menu.create({ data: { name } });
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.menuItem.deleteMany({ where: { menuId: menu.id } }),
|
||||
prisma.menuItem.createMany({
|
||||
data: items.map(item => ({
|
||||
menuId: menu.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order
|
||||
}))
|
||||
})
|
||||
]);
|
||||
await prisma.$transaction(async (tx: any) => {
|
||||
const existingItems = await tx.menuItem.findMany({ where: { menuId: menu!.id } });
|
||||
const existingItemIds = existingItems.map((i: any) => i.id);
|
||||
const payloadItemIds = items.map(i => i.id).filter(id => id != null);
|
||||
const itemsToDelete = existingItemIds.filter((id: number) => !payloadItemIds.includes(id));
|
||||
|
||||
if (itemsToDelete.length > 0) {
|
||||
await tx.menuItem.deleteMany({ where: { id: { in: itemsToDelete } } });
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
if (item.id && existingItemIds.includes(item.id)) {
|
||||
await tx.menuItem.update({
|
||||
where: { id: item.id },
|
||||
data: { label: item.label, url: item.url, order: item.order }
|
||||
});
|
||||
if (item.translations?.length) {
|
||||
for (const t of item.translations) {
|
||||
await tx.menuItemTranslation.upsert({
|
||||
where: { menuItemId_langCode: { menuItemId: item.id, langCode: t.langCode } },
|
||||
update: { label: t.label },
|
||||
create: { menuItemId: item.id, langCode: t.langCode, label: t.label }
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await tx.menuItem.create({
|
||||
data: {
|
||||
menuId: menu!.id,
|
||||
label: item.label,
|
||||
url: item.url,
|
||||
order: item.order,
|
||||
translations: item.translations?.length ? {
|
||||
create: item.translations.map(t => ({
|
||||
langCode: t.langCode,
|
||||
label: t.label
|
||||
}))
|
||||
} : undefined
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return await prisma.menu.findUnique({
|
||||
where: { name },
|
||||
include: { items: { orderBy: { order: "asc" } } }
|
||||
include: { items: { orderBy: { order: "asc" }, include: { translations: true } } }
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -99,7 +125,11 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
id: t.Optional(t.Number()),
|
||||
label: t.String(),
|
||||
url: t.String(),
|
||||
order: t.Number()
|
||||
order: t.Number(),
|
||||
translations: t.Optional(t.Array(t.Object({
|
||||
langCode: t.String(),
|
||||
label: t.String()
|
||||
})))
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -110,6 +140,12 @@ export const menusRoute = new Elysia({ prefix: "/menus" })
|
||||
return { error: "Invalid menu item ID" };
|
||||
}
|
||||
|
||||
const menuItem = await prisma.menuItem.findUnique({ where: { id } });
|
||||
if (!menuItem) {
|
||||
set.status = 404;
|
||||
return { error: "Menu item not found. Please ensure database is seeded or the item exists." };
|
||||
}
|
||||
|
||||
const payload = body as any;
|
||||
const results: any[] = [];
|
||||
const LANGS = ['th', 'en', 'ja', 'zh', 'vi', 'ko'];
|
||||
|
||||
@@ -51,7 +51,9 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
newsletterTitle: t.Optional(t.String()),
|
||||
newsletterDescription: t.Optional(t.String()),
|
||||
newsletterPlaceholder: t.Optional(t.String()),
|
||||
newsletterButtonText: t.Optional(t.String())
|
||||
newsletterButtonText: t.Optional(t.String()),
|
||||
footerSupportHeading: t.Optional(t.Any()),
|
||||
footerCopyrightText: t.Optional(t.Any())
|
||||
})
|
||||
})
|
||||
.patch("/translations", async ({ body, set }) => {
|
||||
|
||||
@@ -69,6 +69,53 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
required: ["env"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "get_menu",
|
||||
description: "Fetch a specific menu and its items (including translations)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" }
|
||||
},
|
||||
required: ["name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "upsert_menu",
|
||||
description: "Update or create a complete menu by replacing its items. Uses proper diff & sync for translations.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string", description: "Name of the menu (e.g. main, footer-explore)" },
|
||||
items: {
|
||||
type: "array",
|
||||
description: "Array of menu items",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "number", description: "ID of existing item (optional)" },
|
||||
label: { type: "string", description: "Label of the menu item" },
|
||||
url: { type: "string", description: "URL of the menu item" },
|
||||
order: { type: "number", description: "Order sequence" },
|
||||
translations: {
|
||||
type: "array",
|
||||
description: "Optional array of translations",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
langCode: { type: "string" },
|
||||
label: { type: "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["label", "url", "order"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["name", "items"]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: "ping_backend",
|
||||
description: "Ping the backend to verify connection and authentication",
|
||||
@@ -466,6 +513,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const res = await api.get("/products?limit=1");
|
||||
return { content: [{ type: "text", text: `Connection successful!` }] };
|
||||
}
|
||||
case "get_menu": {
|
||||
const res = await api.get(`/menus/${params.name}`);
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "upsert_menu": {
|
||||
const res = await api.put(`/menus/${params.name}`, { items: params.items });
|
||||
return { content: [{ type: "text", text: JSON.stringify(res.data, null, 2) }] };
|
||||
}
|
||||
case "search_products": {
|
||||
const search = params.search ? `&search=${encodeURIComponent(params.search)}` : "";
|
||||
const limit = params.limit ? `&limit=${params.limit}` : "&limit=10";
|
||||
|
||||
@@ -8,11 +8,8 @@ import ShopPage from './pages/ShopPage'
|
||||
import CategoryPage from './pages/CategoryPage'
|
||||
import ProductDetailPage from './pages/ProductDetailPage'
|
||||
import SearchPage from './pages/SearchPage'
|
||||
import FaqPage from './pages/FaqPage'
|
||||
import ScentGuidePage from './pages/ScentGuidePage'
|
||||
import ArticleReaderPage from './pages/ArticleReaderPage'
|
||||
import ContactPage from './pages/ContactPage'
|
||||
import AboutPage from './pages/AboutPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
import CheckoutPage from './pages/CheckoutPage'
|
||||
import CouponsPage from './pages/CouponsPage'
|
||||
@@ -64,11 +61,11 @@ function App() {
|
||||
<Route path="/track" element={<TrackOrderPage />} />
|
||||
<Route path="/wishlist" element={<WishlistPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/faq" element={<CustomPage slug="faq" />} />
|
||||
<Route path="/scent-guide" element={<ScentGuidePage />} />
|
||||
<Route path="/article/:slug" element={<ArticleReaderPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="/contact" element={<CustomPage slug="contact" />} />
|
||||
<Route path="/about" element={<CustomPage slug="about" />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/:slug" element={<CustomPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
|
||||
@@ -12,7 +12,15 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
const [supportMenu, setSupportMenu] = useState<MenuItem[]>([])
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
|
||||
const translateMenuLabel = (label: string) => {
|
||||
const translateMenuLabel = (item: MenuItem) => {
|
||||
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
||||
const trans = item.translations.find((t: any) => t.langCode === lang)
|
||||
if (trans && trans.label) {
|
||||
return trans.label
|
||||
}
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'Home': 'nav.home',
|
||||
'Shop': 'nav.shop',
|
||||
@@ -25,7 +33,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery'
|
||||
}
|
||||
return map[label] ? t(map[label]) : label
|
||||
return map[item.label] ? t(map[item.label]) : item.label
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,7 +97,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
{exploreMenu.length > 0 ? (
|
||||
exploreMenu.map(item => (
|
||||
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{translateMenuLabel(item.label)}</Link>
|
||||
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{translateMenuLabel(item)}</Link>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
@@ -116,16 +124,18 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.support', 'Support & Legal')}</h4>
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">
|
||||
{settings?.footerSupportHeading || t('footer.support', 'Support & Legal')}
|
||||
</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
{supportMenu.map(item => (
|
||||
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{translateMenuLabel(item.label)}</Link>
|
||||
<Link key={item.id} to={item.url} className="hover:text-white transition-colors">{translateMenuLabel(item)}</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="text-[10px] text-slate-600 uppercase tracking-widest">
|
||||
© {new Date().getFullYear()} HomAroma. {t('footer.rights')}
|
||||
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} HomAroma. ${t('footer.rights')}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,15 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
setLangSheetOpen(false)
|
||||
}
|
||||
|
||||
const translateMenuLabel = (label: string) => {
|
||||
const translateMenuLabel = (item: MenuItem) => {
|
||||
const lang = localStorage.getItem('homaroma_lang') || 'en'
|
||||
if (lang !== 'en' && item.translations && item.translations.length > 0) {
|
||||
const trans = item.translations.find((t: any) => t.langCode === lang)
|
||||
if (trans && trans.label) {
|
||||
return trans.label
|
||||
}
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {
|
||||
'Home': 'nav.home',
|
||||
'Shop': 'nav.shop',
|
||||
@@ -57,7 +65,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
'Track Order': 'nav.trackOrder',
|
||||
'Delivery & Returns': 'nav.delivery'
|
||||
}
|
||||
return map[label] ? t(map[label]) : label
|
||||
return map[item.label] ? t(map[item.label]) : item.label
|
||||
}
|
||||
// Detect scroll and announcement bar presence
|
||||
useEffect(() => {
|
||||
@@ -145,7 +153,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
{menuItems.length > 0 ? (
|
||||
menuItems.map(item => (
|
||||
<NavLink key={item.id} to={item.url} end={item.url === '/'} className={navLinkClass}>
|
||||
{translateMenuLabel(item.label)}
|
||||
{translateMenuLabel(item)}
|
||||
</NavLink>
|
||||
))
|
||||
) : (
|
||||
@@ -433,7 +441,7 @@ export default function Navbar({ categories = [], topOffsetClassName = 'top-0' }
|
||||
{/* Drawer Nav Links */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
||||
{(menuItems.length > 0 ? [
|
||||
...menuItems.map(item => ({ to: item.url, label: translateMenuLabel(item.label), end: item.url === '/' })),
|
||||
...menuItems.map(item => ({ to: item.url, label: translateMenuLabel(item), end: item.url === '/' })),
|
||||
...(customer ? [{ to: '/profile', label: `👤 ${t('nav.profile') || 'My Profile'}` }] : []),
|
||||
] : [
|
||||
{ to: '/', label: t('nav.home'), end: true },
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"placeholder": "Search scents, categories...",
|
||||
"quickLinks": "Quick Links",
|
||||
"allProducts": "All Products"
|
||||
}
|
||||
},
|
||||
"trackOrder": "Track Order",
|
||||
"delivery": "Delivery & Returns"
|
||||
},
|
||||
"cart": {
|
||||
"title": "Your Sanctuary",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"placeholder": "香りを検索...",
|
||||
"quickLinks": "クイックリンク",
|
||||
"allProducts": "すべての商品"
|
||||
}
|
||||
},
|
||||
"trackOrder": "注文追跡",
|
||||
"delivery": "配送と返品"
|
||||
},
|
||||
"cart": {
|
||||
"title": "カート",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"placeholder": "향기 검색...",
|
||||
"quickLinks": "빠른 링크",
|
||||
"allProducts": "모든 제품"
|
||||
}
|
||||
},
|
||||
"trackOrder": "주문 배송조회",
|
||||
"delivery": "배송 및 반품"
|
||||
},
|
||||
"cart": {
|
||||
"title": "장바구니",
|
||||
|
||||
@@ -20,7 +20,9 @@
|
||||
"placeholder": "ค้นหากลิ่น, คอลเลกชัน...",
|
||||
"quickLinks": "ลิงก์ด่วน",
|
||||
"allProducts": "สินค้าทั้งหมด"
|
||||
}
|
||||
},
|
||||
"trackOrder": "ติดตามสถานะสินค้า",
|
||||
"delivery": "การจัดส่งและการคืนสินค้า"
|
||||
},
|
||||
"cart": {
|
||||
"title": "ตะกร้าสินค้า",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"placeholder": "Tìm mùi hương...",
|
||||
"quickLinks": "Liên kết nhanh",
|
||||
"allProducts": "Tất cả sản phẩm"
|
||||
}
|
||||
},
|
||||
"trackOrder": "Theo dõi đơn hàng",
|
||||
"delivery": "Giao hàng & Hoàn trả"
|
||||
},
|
||||
"cart": {
|
||||
"title": "Giỏ hàng",
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
"placeholder": "搜索香气...",
|
||||
"quickLinks": "快速链接",
|
||||
"allProducts": "所有商品"
|
||||
}
|
||||
},
|
||||
"trackOrder": "追踪订单",
|
||||
"delivery": "配送与退货"
|
||||
},
|
||||
"cart": {
|
||||
"title": "购物车",
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
icon: '🌱',
|
||||
title: 'Pure Origins',
|
||||
desc: 'Every extract is sourced directly from certified organic farms in Northern Thailand, Vietnam, and Southern France. We work exclusively with small-batch artisan growers.',
|
||||
},
|
||||
{
|
||||
icon: '⚗️',
|
||||
title: 'Artisan Craft',
|
||||
desc: 'Each formula is cold-extracted and hand-blended in our Bangkok studio. No heat processing means every aromatic molecule remains intact and therapeutically potent.',
|
||||
},
|
||||
{
|
||||
icon: '♾️',
|
||||
title: 'Zero Compromise',
|
||||
desc: 'No synthetic fragrance. No parabens. No animal testing. Our commitment to clean formulation is absolute — what touches your senses should honour your wellbeing.',
|
||||
},
|
||||
]
|
||||
|
||||
const STATS = [
|
||||
{ value: '45+', label: 'Unique Scents' },
|
||||
{ value: '5', label: 'Product Lines' },
|
||||
{ value: '2019', label: 'Founded' },
|
||||
{ value: '100%', label: 'Natural Extracts' },
|
||||
]
|
||||
|
||||
export default function AboutPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative min-h-[60vh] flex items-center justify-center overflow-hidden pt-24">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-night-800/60 via-night-900 to-night-900" />
|
||||
<div className="absolute inset-0 opacity-10"
|
||||
style={{ backgroundImage: 'radial-gradient(circle at 30% 50%, rgba(102,252,241,0.3) 0%, transparent 60%), radial-gradient(circle at 80% 20%, rgba(69,162,158,0.2) 0%, transparent 50%)' }}
|
||||
/>
|
||||
<div className="relative z-10 text-center px-6 max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Our Story</p>
|
||||
<h1 className="text-6xl font-bold leading-tight">
|
||||
Born from a passion for <span className="text-transparent bg-clip-text bg-gradient-to-r from-cyan-glow to-spa-stone italic font-serif">stillness</span>
|
||||
</h1>
|
||||
<p className="text-slate-300 text-lg leading-relaxed">
|
||||
HomAroma began in 2019 as a one-person studio in Bangkok, crafting small batches of botanical aroma blends for friends and family. What started as a personal journey toward calm became a philosophy — that our environment shapes how we feel, think, and rest.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Stats Band */}
|
||||
<section className="py-12 px-6 bg-white/3 border-y border-spa-stone/10">
|
||||
<div className="max-w-4xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-8 text-center">
|
||||
{STATS.map(s => (
|
||||
<div key={s.label} className="space-y-1">
|
||||
<div className="text-4xl font-bold text-spa-forest">{s.value}</div>
|
||||
<div className="text-xs uppercase tracking-widest text-slate-400">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Mission Section */}
|
||||
<section className="py-24 px-6">
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-6">
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-semibold opacity-70">Our Mission</p>
|
||||
<h2 className="text-4xl font-bold leading-tight">
|
||||
Transforming spaces into sanctuaries of peace
|
||||
</h2>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
We believe that scent is the fastest pathway to emotional transformation. A single breath of the right blend can dissolve anxiety, sharpen focus, or unlock creativity. Our mission is to make that transformation accessible — through products that are as beautiful as they are effective.
|
||||
</p>
|
||||
<p className="text-slate-400 leading-relaxed text-sm">
|
||||
Every HomAroma product is designed with intention: the bg-white shadow-sm border border-spa-stone/10 bottles are reusable, the packaging is recycled, and the formulas are written to be understood by anyone — not hidden behind chemical jargon.
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-80 rounded-2xl overflow-hidden bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-teal-900/30 via-night-800/30 to-purple-900/20" />
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center space-y-3">
|
||||
<div className="text-7xl">🌿</div>
|
||||
<p className="text-white/30 text-sm uppercase tracking-widest">HomAroma Studio, Bangkok</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Three Pillars */}
|
||||
<section className="py-24 px-6 bg-white border-b border-spa-stone/10 border-y border-spa-stone/10">
|
||||
<div className="max-w-6xl mx-auto space-y-12">
|
||||
<div className="text-center space-y-3">
|
||||
<p className="text-spa-forest uppercase tracking-[0.3em] text-xs font-semibold opacity-70">What We Stand For</p>
|
||||
<h2 className="text-4xl font-bold">Three Pillars</h2>
|
||||
<div className="w-12 h-px bg-spa-forest text-white mx-auto" />
|
||||
</div>
|
||||
<div className="grid md:grid-cols-3 gap-8">
|
||||
{PILLARS.map(p => (
|
||||
<div key={p.title} className="bg-white shadow-sm border border-spa-stone/10 rounded-2xl p-8 border border-spa-stone/10 hover:border-spa-forest transition-all duration-500 space-y-4">
|
||||
<div className="text-4xl">{p.icon}</div>
|
||||
<h3 className="text-xl font-bold">{p.title}</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed">{p.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="py-24 px-6 text-center space-y-6">
|
||||
<h2 className="text-4xl font-bold">Ready to find your scent?</h2>
|
||||
<p className="text-slate-400 max-w-md mx-auto">
|
||||
Let our Scent Guide help you discover the perfect blend for your moment.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<Link
|
||||
to="/scent-guide"
|
||||
className="px-10 py-4 bg-white text-spa-forest font-bold rounded-sm tracking-widest uppercase hover:bg-spa-forest text-white hover:shadow-[0_0_30px_rgba(102,252,241,0.4)] transition-all duration-500"
|
||||
>
|
||||
Take the Scent Guide
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="px-10 py-4 bg-white shadow-sm border border-spa-stone/10 text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
Shop All Products
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE, type Article, type Category } from '../types'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
@@ -7,12 +8,13 @@ import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function ArticleReaderPage() {
|
||||
const { slug } = useParams()
|
||||
const { i18n } = useTranslation()
|
||||
const [article, setArticle] = useState<Article | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
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/${slug}`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error()
|
||||
@@ -21,7 +23,7 @@ export default function ArticleReaderPage() {
|
||||
.then(setArticle)
|
||||
.catch(() => setArticle(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
}, [slug, i18n.language])
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Loader2 } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const { t } = useTranslation()
|
||||
const { t, i18n } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { items, getCartTotal, clearCart } = useCartStore()
|
||||
const { customer } = useAuthStore()
|
||||
@@ -108,7 +108,7 @@ export default function CheckoutPage() {
|
||||
.catch(err => console.error("Could not load settings:", err))
|
||||
.finally(() => setLoadingSettings(false))
|
||||
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
|
||||
@@ -127,7 +127,7 @@ export default function CheckoutPage() {
|
||||
// So we'll handle this in another effect or let the user click apply if needed.
|
||||
// Better yet, we can validate it immediately if subtotal is available.
|
||||
}
|
||||
}, [])
|
||||
}, [i18n.language])
|
||||
|
||||
// Kick out if cart empty
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
import { API_BASE, type Category, type PageSection } from '../types'
|
||||
|
||||
type FormState = { name: string; email: string; subject: string; message: string }
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||
|
||||
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
|
||||
|
||||
export default function ContactPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/pages/contact-layout`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Page not found');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.sections) {
|
||||
setCmsSections(data.sections);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('Contact dynamic layout sections not loaded:', err.message);
|
||||
});
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('sending')
|
||||
// Simulate API call (replace with real endpoint later)
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
setStatus('sent')
|
||||
setForm({ name: '', email: '', subject: '', message: '' })
|
||||
}
|
||||
|
||||
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'));
|
||||
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={announcementSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Header */}
|
||||
{heroSections.length > 0 ? (
|
||||
<div className="pt-20">
|
||||
<DynamicPageRenderer
|
||||
sections={heroSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
|
||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
|
||||
<h1 className="text-5xl font-bold">Contact Us</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">
|
||||
Have a question, custom request, or partnership idea? We'd love to hear from you.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
|
||||
{/* Contact Info */}
|
||||
<div className="md:col-span-2 space-y-8">
|
||||
<div>
|
||||
<h2 className="font-bold text-xl mb-6">Reach Us Directly</h2>
|
||||
<div className="space-y-5">
|
||||
{[
|
||||
{ icon: '📧', label: 'Email', value: 'hello@homaroma.shop', href: 'mailto:hello@homaroma.shop' },
|
||||
{ icon: '📱', label: 'LINE Official', value: '@homaroma', href: '#' },
|
||||
{ icon: '📸', label: 'Instagram', value: '@homaroma', href: '#' },
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
].map(item => (
|
||||
<a key={item.label} href={item.href} className="flex items-start gap-4 group">
|
||||
<div className="bg-white shadow-sm border border-spa-stone/10 w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 group-hover:bg-spa-forest/10 transition-colors">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">{item.label}</p>
|
||||
<p className="text-white text-sm font-medium group-hover:text-spa-forest transition-colors">{item.value}</p>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white shadow-sm border border-spa-stone/10 rounded-2xl p-6 border border-spa-stone/10 space-y-3">
|
||||
<h3 className="font-bold text-sm uppercase tracking-widest">Response Time</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Email</span>
|
||||
<span className="text-spa-forest font-medium">~2 hours</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">LINE</span>
|
||||
<span className="text-spa-forest font-medium">~30 minutes</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Instagram DM</span>
|
||||
<span className="text-spa-forest font-medium">~4 hours</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 pt-2 uppercase tracking-wider">Mon–Sat, 9:00–18:00 (ICT)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Contact Form */}
|
||||
<div className="md:col-span-3">
|
||||
{status === 'sent' ? (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<div className="text-center space-y-5 py-16">
|
||||
<div className="text-6xl animate-bounce">✉️</div>
|
||||
<h2 className="text-2xl font-bold">Message Sent!</h2>
|
||||
<p className="text-slate-400">Thank you for reaching out. We'll get back to you within 2 hours.</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="mt-4 px-8 py-3 bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10 hover:border-spa-forest font-bold uppercase tracking-widest text-xs transition-all rounded-lg"
|
||||
>
|
||||
Send Another
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Your name"
|
||||
className="w-full bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10 focus:border-spa-forest rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Email *</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="your@email.com"
|
||||
className="w-full bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10 focus:border-spa-forest rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Subject *</label>
|
||||
<select
|
||||
required
|
||||
name="subject"
|
||||
value={form.subject}
|
||||
onChange={handleChange}
|
||||
className="w-full bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10 focus:border-spa-forest rounded-xl px-4 py-3.5 bg-white outline-none text-sm text-white transition-colors"
|
||||
>
|
||||
<option value="" disabled>Select a subject</option>
|
||||
{SUBJECTS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Message *</label>
|
||||
<textarea
|
||||
required
|
||||
name="message"
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
rows={6}
|
||||
placeholder="Tell us about your inquiry..."
|
||||
className="w-full bg-white shadow-sm border border-spa-stone/10 border border-spa-stone/10 focus:border-spa-forest rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 resize-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
className="w-full py-4 bg-white text-spa-forest font-bold uppercase tracking-widest text-sm rounded-xl hover:bg-spa-forest text-white hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||
>
|
||||
{status === 'sending' ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Sending...
|
||||
</>
|
||||
) : 'Send Message →'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={bottomSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import EmptyState from '../components/EmptyState'
|
||||
@@ -20,6 +21,7 @@ interface Coupon {
|
||||
|
||||
export default function CouponsPage() {
|
||||
const navigate = useNavigate()
|
||||
const { i18n } = useTranslation()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [coupons, setCoupons] = useState<Coupon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
@@ -27,7 +29,7 @@ export default function CouponsPage() {
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
@@ -45,10 +47,10 @@ export default function CouponsPage() {
|
||||
.catch((err) => {
|
||||
console.log('Coupons dynamic layout sections not loaded:', err.message);
|
||||
});
|
||||
}, [])
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
@@ -64,7 +66,7 @@ export default function CouponsPage() {
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
}, [i18n.language])
|
||||
|
||||
const formatDiscount = (type: string, value: number) => {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
|
||||
@@ -1,187 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
import { API_BASE, type Category, type PageSection } from '../types'
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
category: 'Products',
|
||||
icon: '🌿',
|
||||
items: [
|
||||
{ q: 'What are the ingredients in your aroma products?', a: 'All HomAroma products are crafted from 100% natural botanical extracts, pure essential oils, and pharmaceutical-grade carrier bases. We never use synthetic fragrances, parabens, or harmful chemicals. Each product label lists full ingredient transparency.' },
|
||||
{ q: 'How long does each product last?', a: 'Our Reed Diffusers last 4–8 weeks depending on room size and airflow. Aroma Oils (30ml) last approximately 3–4 months with normal use. Room Sprays (100ml) provide 60–80 uses. Car Diffusers cartridges last up to 45 days.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns — we are happy to help identify suitable options.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation, ideal for large rooms and commercial spaces.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Usage',
|
||||
icon: '💡',
|
||||
items: [
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents for longest lasting performance.' },
|
||||
{ q: 'Can I mix different scents?', a: 'Yes! Blending scents is an art form we encourage. A general rule: combine no more than 3 scents at a time, using one dominant (60%), one supporting (30%), and one accent (10%). Our Scent Guide can help you find complementary combinations.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results and to avoid olfactory fatigue, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shipping & Orders',
|
||||
icon: '📦',
|
||||
items: [
|
||||
{ q: 'How long does shipping take?', a: 'Standard delivery within Bangkok takes 1–2 business days. Provincial orders take 2–4 business days. Express same-day delivery is available in Bangkok for orders placed before 12:00 PM.' },
|
||||
{ q: 'Do you ship internationally?', a: 'Currently we ship within Thailand only. International shipping is planned for late 2025. Sign up for our newsletter to be notified when we expand to your region.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost. Contact us at hello@homaroma.shop with your order number.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Scent Guide',
|
||||
icon: '🧪',
|
||||
items: [
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm, welcoming notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus. Use our interactive Scent Guide for personalized recommendations.' },
|
||||
{ q: 'Can I request custom scent blending?', a: 'Yes! Our bespoke blending service is available for orders of 6 units or more. Contact us at least 10 business days before your desired delivery date. Custom blending fees start at ฿500 per unique formula.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function FaqPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
||||
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/pages/faq-layout`)
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error('Page not found');
|
||||
return r.json();
|
||||
})
|
||||
.then((data) => {
|
||||
if (data && data.sections) {
|
||||
setCmsSections(data.sections);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('FAQ dynamic layout sections not loaded:', err.message);
|
||||
});
|
||||
}, [])
|
||||
|
||||
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
||||
|
||||
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'));
|
||||
const bottomSections = cmsSections.filter(s => s.isActive && s.type.toUpperCase() !== 'ANNOUNCEMENT_BAR' && s.type.toUpperCase() !== 'HERO' && s.type.toUpperCase() !== 'BANNER');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{announcementSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={announcementSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Header */}
|
||||
{heroSections.length > 0 ? (
|
||||
<div className="pt-20">
|
||||
<DynamicPageRenderer
|
||||
sections={heroSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-spa-stone/10 bg-white">
|
||||
<p className="text-spa-forest uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
|
||||
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">
|
||||
Everything you need to know about HomAroma products, shipping, and care.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
|
||||
{/* Category tabs */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{FAQS.map(({ category, icon }) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 flex items-center gap-2 ${
|
||||
activeCategory === category
|
||||
? 'bg-spa-forest text-white text-spa-forest shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
: 'bg-white shadow-sm border border-spa-stone/10 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span> {category}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ Accordion */}
|
||||
<div className="space-y-3">
|
||||
{activeFaqs?.items.map((item, i) => {
|
||||
const key = `${activeCategory}-${i}`
|
||||
const isOpen = openItems[key]
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`bg-white shadow-sm border border-spa-stone/10 rounded-xl overflow-hidden border transition-all duration-300 ${isOpen ? 'border-spa-forest' : 'border-spa-stone/10'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
className="w-full text-left px-6 py-5 flex justify-between items-start gap-4"
|
||||
>
|
||||
<span className="font-semibold text-sm md:text-base leading-snug">{item.q}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-spa-forest shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div className="px-6 pb-6">
|
||||
<div className="w-full h-px bg-white/5 mb-4" />
|
||||
<p className="text-slate-300 leading-relaxed text-sm">{item.a}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Still have questions? */}
|
||||
<div className="bg-white shadow-sm border border-spa-stone/10 rounded-2xl p-8 text-center space-y-4 border border-spa-stone/10 mt-12">
|
||||
<div className="text-3xl">💬</div>
|
||||
<h3 className="font-bold text-xl">Still have questions?</h3>
|
||||
<p className="text-slate-400 text-sm">Our team is here to help — usually responds within 2 hours.</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block px-8 py-3 bg-white text-spa-forest font-bold rounded-lg uppercase tracking-widest text-xs hover:bg-spa-forest text-white transition-colors duration-300"
|
||||
>
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{bottomSections.length > 0 && (
|
||||
<DynamicPageRenderer
|
||||
sections={bottomSections}
|
||||
categories={categories}
|
||||
categoriesLoading={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 DynamicPageRenderer from '../components/DynamicPageRenderer'
|
||||
@@ -8,6 +9,7 @@ import { CheckCircle2, UploadCloud, Loader2, Copy, CreditCard, QrCode, Landmark
|
||||
|
||||
export default function OrderSuccessPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { i18n } = useTranslation()
|
||||
const [order, setOrder] = useState<Order | null>(null)
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -150,7 +152,7 @@ export default function OrderSuccessPage() {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/settings`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/pages/order-success-layout`).then(r => r.ok ? r.json() : null).catch(() => null)
|
||||
])
|
||||
.then(([orderData, settingsData, catData, pageData]) => {
|
||||
@@ -163,7 +165,7 @@ export default function OrderSuccessPage() {
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
}, [id, i18n.language])
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!settings) return
|
||||
|
||||
@@ -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 { API_BASE, formatPrice, type Category, type Order, type CustomerAddress } from '../types'
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
|
||||
export default function ProfilePage() {
|
||||
const { token, customer, setAuth } = useAuthStore()
|
||||
const { i18n } = useTranslation()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
// Tab State
|
||||
@@ -58,11 +60,11 @@ export default function ProfilePage() {
|
||||
|
||||
// Fetch Categories
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
}, [i18n.language])
|
||||
|
||||
// Fetch Customer Profile details & Points dynamically
|
||||
const fetchProfile = () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, 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'
|
||||
@@ -18,6 +19,7 @@ interface OrderHistoryItem {
|
||||
export default function TrackOrderPage() {
|
||||
const navigate = useNavigate()
|
||||
const { customer } = useAuthStore()
|
||||
const { i18n } = useTranslation()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [localOrders, setLocalOrders] = useState<number[]>([])
|
||||
const [historyOrders, setHistoryOrders] = useState<OrderHistoryItem[]>([])
|
||||
@@ -46,7 +48,7 @@ export default function TrackOrderPage() {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
@@ -67,7 +69,7 @@ export default function TrackOrderPage() {
|
||||
setLocalOrders(JSON.parse(storedStr))
|
||||
}
|
||||
}
|
||||
}, [customer])
|
||||
}, [customer, i18n.language])
|
||||
|
||||
const handleTrackForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
|
||||
@@ -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 ProductCard from '../components/ProductCard'
|
||||
@@ -11,14 +12,15 @@ import { Heart } from 'lucide-react'
|
||||
export default function WishlistPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const { items } = useWishlistStore()
|
||||
const { i18n } = useTranslation()
|
||||
const [cmsSections, setCmsSections] = useState<PageSection[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
fetch(`${API_BASE}/categories?lang=${i18n.language}`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
}, [i18n.language])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/pages/wishlist-layout`)
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface StoreSettings {
|
||||
newsletterDescription?: string | null
|
||||
newsletterPlaceholder?: string | null
|
||||
newsletterButtonText?: string | null
|
||||
footerSupportHeading?: any | null
|
||||
footerCopyrightText?: any | null
|
||||
}
|
||||
|
||||
export interface MenuItem {
|
||||
@@ -113,6 +115,7 @@ export interface MenuItem {
|
||||
label: string
|
||||
url: string
|
||||
order: number
|
||||
translations?: any[]
|
||||
}
|
||||
|
||||
export interface Menu {
|
||||
|
||||
Reference in new issue
Block a user