x
This commit is contained in:
1 parent
f7ac94ca0d
commit
335f3b4f59
41 files changed
+799
-403
No files matched your search
@@ -0,0 +1,78 @@
|
|||||||
|
---
|
||||||
|
name: theme-compliance-auditor
|
||||||
|
description: Theme Compliance Auditor Agent ตรวจสอบว่า Component ใช้สีจาก Admin (CMS Theme) ผ่าน CSS Variables (ตระกูล pop-*) โดยห้าม Hardcode และห้ามมักง่ายอ่าน API มายัดสีตรงๆ
|
||||||
|
---
|
||||||
|
|
||||||
|
# Skill: Theme Compliance Auditor Agent
|
||||||
|
|
||||||
|
## Role
|
||||||
|
คุณคือ **Theme Compliance Auditor Agent** ประจำโปรเจกต์ HomAroma
|
||||||
|
หน้าที่หลักของคุณคือ "ผู้พิทักษ์ความถูกต้องของโครงสร้างสีแบบ Dynamic" คุณต้องรับประกันว่า โค้ด Frontend ทั้งหมด (โดยเฉพาะใน `apps/spa`) มีความเคารพต่อการตั้งค่าสีจากระบบ CMS Admin แบบ 100%
|
||||||
|
|
||||||
|
## Context: กลไกการทำงานของระบบสี (System Flow)
|
||||||
|
1. แอดมินตั้งค่าสีในระบบ CMS (เช่น BRAND PRIMARY, SURFACE PAGE, TEXT MAIN)
|
||||||
|
2. ค่าเหล่านี้ถูกส่งมาผ่าน API (`/api/settings`)
|
||||||
|
3. ในฝั่ง `apps/spa` เรามี `ThemeInjector.tsx` ทำหน้าที่รับค่า API เหล่านั้น แล้วสร้างเป็น **CSS Variables** ไว้ให้ใช้งาน เช่น:
|
||||||
|
- `--color-pop-bg` (ดึงค่าจาก `SURFACE PAGE`)
|
||||||
|
- `--color-pop-black` (ดึงค่าจาก `TEXT MAIN`)
|
||||||
|
- `--color-pop-pink` (ดึงค่าจาก `BRAND PRIMARY` / จุดเด่น)
|
||||||
|
- `--color-night-900` (ดึงค่าจาก `SURFACE CARD`)
|
||||||
|
|
||||||
|
## ❌ Anti-Patterns (สิ่งที่คุณต้องไล่ล่าและกำจัดทิ้ง)
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **1. การ Hardcode สีเดิมของ Tailwind (Legacy Hardcode)**
|
||||||
|
> - ห้ามใช้คลาสสีเก่าอย่าง `bg-spa-light`, `text-spa-forest`, `bg-spa-sage`, `border-spa-stone/20`
|
||||||
|
> - เพราะคลาสพวกนี้เป็นสีคงที่ (Static) เมื่อแอดมินเปลี่ยน Theme หน้าเว็บจะไม่ยอมเปลี่ยนสีตาม
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **2. ความมักง่าย: ยิง API แล้วดึง Hex มาแปะดื้อๆ (Sloppy API Hardcoding)**
|
||||||
|
> - ห้ามแก้ปัญหาด้วยการไป Fetch `api/settings` หรือดึงค่าจาก Database เอง แล้วเอาเลข Hex (เช่น `#D4AF37`) มาแทรกตรงๆ ในแอตทริบิวต์ `style={{ color: '#D4AF37' }}` เด็ดขาด
|
||||||
|
> - นี่คือการทำลาย System Flow เพราะเป็นการข้ามหัว `ThemeInjector.tsx` และทำให้ระบบพังเมื่อแอดมินเปลี่ยนสีภายหลัง
|
||||||
|
|
||||||
|
> [!WARNING]
|
||||||
|
> **3. ละเลยสีสถานะ (Hover / Active / Opacity)**
|
||||||
|
> - การเปลี่ยนแค่สีหลักไม่พอ หากมี Hover ที่เป็นสี Hardcode (`hover:bg-spa-light`) ต้องแก้เป็น Dynamic (`hover:bg-pop-black/5`)
|
||||||
|
> - หากมีปุ่มสถานะแบบ Active (เช่นปุ่มเลือกขนาด) ต้องจัดการสีแบบ Invert อย่างสากล (เช่น สลับไปใช้ `bg-pop-black text-pop-bg`)
|
||||||
|
|
||||||
|
## ✅ Fixing Patterns (วิธีแก้ไขที่ถูกต้อง)
|
||||||
|
|
||||||
|
### การแก้สีพื้นฐาน
|
||||||
|
```tsx
|
||||||
|
// ❌ Bad (Legacy Hardcode)
|
||||||
|
<div className="bg-spa-light text-spa-forest border-spa-stone/10">
|
||||||
|
|
||||||
|
// ✅ Good (Dynamic Theme Compliance)
|
||||||
|
<div className="bg-pop-bg text-pop-black border-pop-black/10">
|
||||||
|
```
|
||||||
|
|
||||||
|
### การแก้ปุ่มเน้นสีแบรนด์ (Brand Accent)
|
||||||
|
```tsx
|
||||||
|
// ❌ Bad
|
||||||
|
<button className="bg-spa-forest text-white">
|
||||||
|
|
||||||
|
// ✅ Good (ใช้ pop-pink ซึ่งผูกกับ Brand Primary)
|
||||||
|
<button className="bg-pop-pink text-pop-bg">
|
||||||
|
```
|
||||||
|
|
||||||
|
### การแก้ Hover State ด้วยความโปร่งแสง
|
||||||
|
```tsx
|
||||||
|
// ❌ Bad
|
||||||
|
<a className="hover:bg-spa-sage text-spa-stone hover:text-spa-forest">
|
||||||
|
|
||||||
|
// ✅ Good (ใช้ opacity หรือดึงสีอักษรมาทำสีพื้นหลังจางๆ)
|
||||||
|
<a className="text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Audit Workflow (กระบวนการทำงานของคุณ)
|
||||||
|
|
||||||
|
1. **รับคำสั่ง:** ตรวจสอบชื่อไฟล์ / Component Block ที่รับมอบหมาย
|
||||||
|
2. **Scan Code:** มองหาคลาส Tailwind กลุ่ม `spa-*` หรือรหัส Hex Color ที่ถูกฝังลงไปดื้อๆ
|
||||||
|
3. **Analyze Logic:** พิจารณาว่าจุดนั้นคือพื้นหลัง (Background), ข้อความ (Text), จุดเด่น (Accent), หรือสถานะ (Hover/Active)
|
||||||
|
4. **Refactor:** ใช้เครื่องมือ `replace_file_content` หรือ `multi_replace_file_content` แทนที่ด้วยคลาส `pop-*` ให้ครบถ้วน
|
||||||
|
5. **Verify Context:** ตรวจทานความถูกต้องของ Contrast เช่น ถ้าพื้นหลังเป็น `bg-pop-black` ตัวหนังสือต้องเป็น `text-pop-bg` (ห้ามใช้สีมืดชนกันเอง)
|
||||||
|
|
||||||
|
## ⚖️ Disciplines
|
||||||
|
|
||||||
|
- **"Do not break the pipe"**: คุณต้องตระหนักว่าคลาส `pop-pink` ไม่ใช่ "สีชมพู" แต่มันคือ "ท่อลำเลียงสี Brand Primary" ที่มาจาก Admin
|
||||||
|
- **"System Flow is King"**: หน้าที่ของคุณคือผูกโค้ดเข้ากับท่อส่งตัวแปร CSS เหล่านั้น ห้ามดึงน้ำออกจากท่อมาสาดใส่โค้ดเองเด็ดขาด
|
||||||
@@ -21,6 +21,7 @@ When working in this repository, read this file first.
|
|||||||
- QA, user flow, regression, empty/error states, or release readiness: `.agents/skills/qa-product-auditor/SKILL.md`
|
- QA, user flow, regression, empty/error states, or release readiness: `.agents/skills/qa-product-auditor/SKILL.md`
|
||||||
- Brand, SEO, positioning, or marketing copy: `.agents/skills/brand-strategist/SKILL.md`
|
- Brand, SEO, positioning, or marketing copy: `.agents/skills/brand-strategist/SKILL.md`
|
||||||
- Design system, color contrast, readability, or token compliance: `.agents/skills/design-system-auditor/SKILL.md`
|
- Design system, color contrast, readability, or token compliance: `.agents/skills/design-system-auditor/SKILL.md`
|
||||||
|
- Dynamic Theme compliance, cleaning up hardcoded UI colors: `.agents/skills/theme-compliance-auditor/SKILL.md`
|
||||||
|
|
||||||
## Operating Rules
|
## Operating Rules
|
||||||
- Do not guess field names, API contracts, routes, schema, design tokens, or component names.
|
- Do not guess field names, API contracts, routes, schema, design tokens, or component names.
|
||||||
|
|||||||
@@ -948,15 +948,15 @@ export default function PageBuilderPage() {
|
|||||||
section.payload?.title
|
section.payload?.title
|
||||||
|| section.payload?.tagline
|
|| section.payload?.tagline
|
||||||
|| section.payload?.quote
|
|| section.payload?.quote
|
||||||
|| (section.type === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)
|
|| (section.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' ? section.payload?.slides?.[0] : undefined)
|
||||||
) || `Block #${section.id}`}
|
) || `Block #${section.id}`}
|
||||||
</h3>
|
</h3>
|
||||||
{section.type === 'PRODUCTS' && (
|
{section.type?.toUpperCase() === 'PRODUCTS' && (
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Type: {section.payload?.type} • Limit: {section.payload?.limit || 3} products
|
Type: {section.payload?.type} • Limit: {section.payload?.limit || 3} products
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{section.type === 'ANNOUNCEMENT_BAR' && (
|
{section.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' && (
|
||||||
<p className="text-xs text-slate-400 mt-0.5">
|
<p className="text-xs text-slate-400 mt-0.5">
|
||||||
Slides: {section.payload?.slides?.length || 0} messages
|
Slides: {section.payload?.slides?.length || 0} messages
|
||||||
</p>
|
</p>
|
||||||
@@ -1067,7 +1067,7 @@ export default function PageBuilderPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dynamic Forms based on Type */}
|
{/* Dynamic Forms based on Type */}
|
||||||
{editingSection.type === 'HERO' && (
|
{editingSection.type?.toUpperCase() === 'HERO' && (
|
||||||
<HeroForm
|
<HeroForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
editingLang={editingLang}
|
editingLang={editingLang}
|
||||||
@@ -1079,7 +1079,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'ANNOUNCEMENT_BAR' && (
|
{editingSection.type?.toUpperCase() === 'ANNOUNCEMENT_BAR' && (
|
||||||
<AnnouncementBarForm
|
<AnnouncementBarForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1089,7 +1089,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'NEWSLETTER' && (
|
{editingSection.type?.toUpperCase() === 'NEWSLETTER' && (
|
||||||
<NewsletterForm
|
<NewsletterForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1099,7 +1099,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'EDITORIAL_TILE' && (
|
{editingSection.type?.toUpperCase() === 'EDITORIAL_TILE' && (
|
||||||
<EditorialTileForm
|
<EditorialTileForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1112,7 +1112,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'EDITORIAL_CAROUSEL' && (
|
{editingSection.type?.toUpperCase() === 'EDITORIAL_CAROUSEL' && (
|
||||||
<EditorialCarouselForm
|
<EditorialCarouselForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1127,7 +1127,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'CATEGORIES' && (
|
{editingSection.type?.toUpperCase() === 'CATEGORIES' && (
|
||||||
<CategoriesForm
|
<CategoriesForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1138,7 +1138,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'PRODUCTS' && (
|
{editingSection.type?.toUpperCase() === 'PRODUCTS' && (
|
||||||
<ProductsForm
|
<ProductsForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1149,7 +1149,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'BANNER' && (
|
{editingSection.type?.toUpperCase() === 'BANNER' && (
|
||||||
<BannerForm
|
<BannerForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
editingLang={editingLang}
|
editingLang={editingLang}
|
||||||
@@ -1161,7 +1161,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'TEXT' && (
|
{editingSection.type?.toUpperCase() === 'TEXT' && (
|
||||||
<TextForm
|
<TextForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1171,7 +1171,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'HERO_ARTICLE' && (
|
{editingSection.type?.toUpperCase() === 'HERO_ARTICLE' && (
|
||||||
<HeroArticleForm
|
<HeroArticleForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1184,7 +1184,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'RICH_TEXT' && (
|
{editingSection.type?.toUpperCase() === 'RICH_TEXT' && (
|
||||||
<RichTextForm
|
<RichTextForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
editingLang={editingLang}
|
editingLang={editingLang}
|
||||||
@@ -1193,7 +1193,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'SCENT_NOTES_BREAKDOWN' && (
|
{editingSection.type?.toUpperCase() === 'SCENT_NOTES_BREAKDOWN' && (
|
||||||
<ScentNotesBreakdownForm
|
<ScentNotesBreakdownForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1203,7 +1203,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'QUOTE_CALLOUT' && (
|
{editingSection.type?.toUpperCase() === 'QUOTE_CALLOUT' && (
|
||||||
<QuoteCalloutForm
|
<QuoteCalloutForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
editingLang={editingLang}
|
editingLang={editingLang}
|
||||||
@@ -1212,7 +1212,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'PRODUCT_CAROUSEL' && (
|
{editingSection.type?.toUpperCase() === 'PRODUCT_CAROUSEL' && (
|
||||||
<ProductCarouselForm
|
<ProductCarouselForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1222,7 +1222,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'ACCORDION' && (
|
{editingSection.type?.toUpperCase() === 'ACCORDION' && (
|
||||||
<AccordionForm
|
<AccordionForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1235,7 +1235,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'CONTACT_INFO' && (
|
{editingSection.type?.toUpperCase() === 'CONTACT_INFO' && (
|
||||||
<ContactInfoForm
|
<ContactInfoForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
setEditingSection={setEditingSection}
|
setEditingSection={setEditingSection}
|
||||||
@@ -1245,7 +1245,7 @@ export default function PageBuilderPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{editingSection.type === 'CONTACT_FORM' && (
|
{editingSection.type?.toUpperCase() === 'CONTACT_FORM' && (
|
||||||
<ContactFormForm
|
<ContactFormForm
|
||||||
editingSection={editingSection}
|
editingSection={editingSection}
|
||||||
editingLang={editingLang}
|
editingLang={editingLang}
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 592 KiB |
@@ -32,41 +32,41 @@ export default function CartDrawer() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Drawer */}
|
{/* Drawer */}
|
||||||
<div className="relative w-full max-w-md h-full bg-spa-light shadow-2xl flex flex-col animate-slide-left">
|
<div className="relative w-full max-w-md h-full bg-pop-bg shadow-2xl flex flex-col animate-slide-left">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-6 border-b border-spa-stone/10 flex items-center justify-between bg-spa-light">
|
<div className="p-6 border-b border-pop-black/10 flex items-center justify-between bg-pop-bg">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2 text-pop-black">
|
<h2 className="text-xl font-bold flex items-center gap-2 text-pop-black">
|
||||||
<ShoppingBag className="w-5 h-5 text-spa-forest" />
|
<ShoppingBag className="w-5 h-5 text-pop-pink" />
|
||||||
{t('cart.title')}
|
{t('cart.title')}
|
||||||
<span className="text-sm font-normal text-spa-stone bg-spa-sage px-2 py-0.5 rounded-full ml-2">
|
<span className="text-sm font-normal text-pop-black bg-pop-black/5 px-2 py-0.5 rounded-full ml-2">
|
||||||
{t('cart.items', { count: items.length })}
|
{t('cart.items', { count: items.length })}
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCart(false)}
|
onClick={() => toggleCart(false)}
|
||||||
className="p-2 hover:bg-spa-sage rounded-full transition-colors"
|
className="p-2 hover:bg-pop-black/5 rounded-full transition-colors"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-spa-stone hover:text-pop-black" />
|
<X className="w-5 h-5 text-pop-black/50 hover:text-pop-black" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cart Items */}
|
{/* Cart Items */}
|
||||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
{items.length === 0 ? (
|
{items.length === 0 ? (
|
||||||
<div className="h-full flex flex-col items-center justify-center text-spa-stone space-y-4">
|
<div className="h-full flex flex-col items-center justify-center text-pop-black/50 space-y-4">
|
||||||
<ShoppingBag className="w-16 h-16 opacity-20" />
|
<ShoppingBag className="w-16 h-16 opacity-20" />
|
||||||
<p>{t('cart.empty')}</p>
|
<p>{t('cart.empty')}</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleCart(false)}
|
onClick={() => toggleCart(false)}
|
||||||
className="text-spa-forest hover:underline tracking-widest uppercase text-xs font-bold"
|
className="text-pop-pink hover:underline tracking-widest uppercase text-xs font-bold"
|
||||||
>
|
>
|
||||||
{t('cart.continueShopping')}
|
{t('cart.continueShopping')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
items.map((item) => (
|
items.map((item) => (
|
||||||
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-spa-light border border-spa-stone/10 shadow-sm group relative">
|
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-pop-bg border border-pop-black/10 shadow-sm group relative">
|
||||||
<div className="w-18 h-22 rounded-lg bg-spa-sage/30 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
<div className="w-18 h-22 rounded-lg bg-pop-black/5 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
||||||
<img
|
<img
|
||||||
src={item.productImage || '/images/fallback-product.svg'}
|
src={item.productImage || '/images/fallback-product.svg'}
|
||||||
alt={item.productName}
|
alt={item.productName}
|
||||||
@@ -75,33 +75,33 @@ export default function CartDrawer() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0">
|
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0">
|
||||||
<h3 className="font-bold text-sm text-spa-forest leading-tight pr-6 truncate">{item.productName}</h3>
|
<h3 className="font-bold text-sm text-pop-black leading-tight pr-6 truncate">{item.productName}</h3>
|
||||||
{item.variantSize && (
|
{item.variantSize && (
|
||||||
<p className="text-xs text-spa-stone">{item.variantSize}</p>
|
<p className="text-xs text-pop-black/60">{item.variantSize}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Price row */}
|
{/* Price row */}
|
||||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||||
<span className="text-xs text-spa-stone/80">{formatPrice(item.price)}</span>
|
<span className="text-xs text-pop-black/80">{formatPrice(item.price)}</span>
|
||||||
<span className="text-xs text-spa-stone/60">×</span>
|
<span className="text-xs text-pop-black/60">×</span>
|
||||||
<span className="text-xs text-spa-stone/80">{item.quantity}</span>
|
<span className="text-xs text-pop-black/80">{item.quantity}</span>
|
||||||
<span className="text-xs text-spa-stone/60">=</span>
|
<span className="text-xs text-pop-black/60">=</span>
|
||||||
<span className="text-sm font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</span>
|
<span className="text-sm font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quantity controls */}
|
{/* Quantity controls */}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex items-center gap-2 mt-1">
|
||||||
<div className="flex items-center gap-2 bg-spa-sage/50 rounded-lg border border-spa-stone/5">
|
<div className="flex items-center gap-2 bg-pop-black/5 rounded-lg border border-pop-black/5">
|
||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||||||
className="p-1.5 text-spa-stone hover:text-spa-forest transition-colors"
|
className="p-1.5 text-pop-black/50 hover:text-pop-black transition-colors"
|
||||||
>
|
>
|
||||||
<Minus className="w-3 h-3" />
|
<Minus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<span className="w-5 text-center text-xs font-bold text-pop-black">{item.quantity}</span>
|
<span className="w-5 text-center text-xs font-bold text-pop-black">{item.quantity}</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||||||
className="p-1.5 text-spa-stone hover:text-spa-forest transition-colors"
|
className="p-1.5 text-pop-black/50 hover:text-pop-black transition-colors"
|
||||||
>
|
>
|
||||||
<Plus className="w-3 h-3" />
|
<Plus className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@@ -110,7 +110,7 @@ export default function CartDrawer() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => removeFromCart(item.cartItemId)}
|
onClick={() => removeFromCart(item.cartItemId)}
|
||||||
className="absolute top-0 right-0 p-1.5 text-spa-stone/50 hover:text-rose-500 transition-colors"
|
className="absolute top-0 right-0 p-1.5 text-pop-black/50 hover:text-rose-500 transition-colors"
|
||||||
title="Remove item"
|
title="Remove item"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@@ -214,7 +214,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
|||||||
const total = Math.max(0, subtotalAfterL2 - discountAmount)
|
const total = Math.max(0, subtotalAfterL2 - discountAmount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-5 border-t border-spa-stone/10 bg-spa-light space-y-4">
|
<div className="p-5 border-t border-pop-black/10 bg-pop-bg space-y-4">
|
||||||
|
|
||||||
{/* Coupon Input */}
|
{/* Coupon Input */}
|
||||||
{!savedCoupon && (
|
{!savedCoupon && (
|
||||||
@@ -225,12 +225,12 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
|||||||
value={couponInput}
|
value={couponInput}
|
||||||
onChange={e => setCouponInput(e.target.value.toUpperCase())}
|
onChange={e => setCouponInput(e.target.value.toUpperCase())}
|
||||||
placeholder={t('cart.couponPlaceholder', 'Discount Code')}
|
placeholder={t('cart.couponPlaceholder', 'Discount Code')}
|
||||||
className="flex-1 bg-spa-light border border-spa-stone/20 rounded-lg px-3 py-2 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
|
className="flex-1 bg-pop-bg border border-pop-black/20 rounded-lg px-3 py-2 text-sm text-pop-black focus:border-pop-pink outline-none uppercase tracking-widest placeholder:text-pop-black/50"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={handleApplyCoupon}
|
onClick={handleApplyCoupon}
|
||||||
disabled={couponLoading || !couponInput.trim()}
|
disabled={couponLoading || !couponInput.trim()}
|
||||||
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
|
className="px-4 bg-pop-black/5 hover:bg-pop-black/10 border border-pop-black/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
|
||||||
>
|
>
|
||||||
{couponLoading ? '...' : t('cart.applyCoupon', 'Apply')}
|
{couponLoading ? '...' : t('cart.applyCoupon', 'Apply')}
|
||||||
</button>
|
</button>
|
||||||
@@ -241,30 +241,30 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
|||||||
|
|
||||||
{/* Totals */}
|
{/* Totals */}
|
||||||
<div className="space-y-2 text-sm text-pop-black">
|
<div className="space-y-2 text-sm text-pop-black">
|
||||||
<div className="flex justify-between text-spa-stone">
|
<div className="flex justify-between text-pop-black/60">
|
||||||
<span>ราคาสินค้า</span>
|
<span>ราคาสินค้า</span>
|
||||||
<span>{formatPrice(subtotal)}</span>
|
<span>{formatPrice(subtotal)}</span>
|
||||||
</div>
|
</div>
|
||||||
{promoLevel1Disc > 0 && (
|
{promoLevel1Disc > 0 && (
|
||||||
<div className="flex justify-between text-spa-forest font-medium">
|
<div className="flex justify-between text-pop-pink font-medium">
|
||||||
<span>{promoLevel1?.name}</span>
|
<span>{promoLevel1?.name}</span>
|
||||||
<span>- {formatPrice(promoLevel1Disc)}</span>
|
<span>- {formatPrice(promoLevel1Disc)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{promoLevel2Disc > 0 && (
|
{promoLevel2Disc > 0 && (
|
||||||
<div className="flex justify-between text-spa-forest font-medium">
|
<div className="flex justify-between text-pop-pink font-medium">
|
||||||
<span>{promoLevel2?.name}</span>
|
<span>{promoLevel2?.name}</span>
|
||||||
<span>- {formatPrice(promoLevel2Disc)}</span>
|
<span>- {formatPrice(promoLevel2Disc)}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
|
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
|
||||||
<div className="flex justify-between text-spa-forest font-medium">
|
<div className="flex justify-between text-pop-pink font-medium">
|
||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
ส่วนลดคูปอง
|
ส่วนลดคูปอง
|
||||||
<span className="text-[10px] font-bold bg-spa-sage px-1.5 py-0.5 rounded tracking-widest text-spa-forest">
|
<span className="text-[10px] font-bold bg-pop-pink/10 px-1.5 py-0.5 rounded tracking-widest text-pop-pink">
|
||||||
{savedCoupon}
|
{savedCoupon}
|
||||||
</span>
|
</span>
|
||||||
<button onClick={removeCoupon} className="text-spa-stone/50 hover:text-rose-500 transition-colors" title="ยกเลิก">
|
<button onClick={removeCoupon} className="text-pop-black/50 hover:text-rose-500 transition-colors" title="ยกเลิก">
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
@@ -276,13 +276,13 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
|||||||
<span className="flex items-center gap-1.5">
|
<span className="flex items-center gap-1.5">
|
||||||
<Tag className="w-3 h-3" />
|
<Tag className="w-3 h-3" />
|
||||||
คูปอง <span className="font-bold tracking-widest">{savedCoupon}</span> (ยังไม่ถึงยอดขั้นต่ำ)
|
คูปอง <span className="font-bold tracking-widest">{savedCoupon}</span> (ยังไม่ถึงยอดขั้นต่ำ)
|
||||||
<button onClick={removeCoupon} className="text-spa-stone/50 hover:text-rose-500 transition-colors">
|
<button onClick={removeCoupon} className="text-pop-black/50 hover:text-rose-500 transition-colors">
|
||||||
<X className="w-3 h-3" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex justify-between font-bold text-base pt-2 border-t border-spa-stone/10 text-pop-black">
|
<div className="flex justify-between font-bold text-base pt-2 border-t border-pop-black/10 text-pop-black">
|
||||||
<span>รวมสุทธิ</span>
|
<span>รวมสุทธิ</span>
|
||||||
<span className="text-rose-500">{formatPrice(total)}</span>
|
<span className="text-rose-500">{formatPrice(total)}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -292,7 +292,7 @@ function CartDrawerFooter({ subtotal, onClose, t }: { subtotal: number; onClose:
|
|||||||
<Link
|
<Link
|
||||||
to="/checkout"
|
to="/checkout"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="flex items-center justify-center gap-2 w-full py-4 bg-spa-forest text-spa-light font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-spa-stone hover:shadow-lg transition-all duration-300 group"
|
className="flex items-center justify-center gap-2 w-full py-4 bg-pop-pink text-pop-bg font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-pop-black hover:text-pop-bg hover:shadow-lg transition-all duration-300 group"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-4 h-4" />
|
<ShoppingBag className="w-4 h-4" />
|
||||||
ดำเนินการชำระเงิน
|
ดำเนินการชำระเงิน
|
||||||
|
|||||||
@@ -49,8 +49,8 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
|||||||
to={item.url || '#'}
|
to={item.url || '#'}
|
||||||
className={`flex items-center gap-1 transition-colors py-2 border-b ${
|
className={`flex items-center gap-1 transition-colors py-2 border-b ${
|
||||||
isOpen
|
isOpen
|
||||||
? 'text-spa-forest border-spa-forest'
|
? 'text-pop-black border-pop-black'
|
||||||
: 'text-spa-stone border-transparent hover:text-spa-forest hover:border-spa-forest'
|
: 'text-pop-black/70 border-transparent hover:text-pop-black hover:border-pop-black'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{getLocalizedText(item.label, lang)}
|
{getLocalizedText(item.label, lang)}
|
||||||
@@ -78,11 +78,11 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
|||||||
onMouseEnter={() => handleOpen(uniqueId)}
|
onMouseEnter={() => handleOpen(uniqueId)}
|
||||||
onMouseLeave={handleClose}
|
onMouseLeave={handleClose}
|
||||||
>
|
>
|
||||||
<div className="w-full bg-spa-sage border-b border-black/5 shadow-sm cursor-default">
|
<div className="w-full bg-pop-bg border-b border-pop-black/10 shadow-sm cursor-default">
|
||||||
<div className="flex justify-center gap-16 py-10 max-w-7xl mx-auto px-6">
|
<div className="flex justify-center gap-16 py-10 max-w-7xl mx-auto px-6">
|
||||||
{item.columns!.map((col, idx) => (
|
{item.columns!.map((col, idx) => (
|
||||||
<div key={idx} className="flex flex-col min-w-[140px] text-left">
|
<div key={idx} className="flex flex-col min-w-[140px] text-left">
|
||||||
<h4 className="text-[10px] uppercase tracking-widest text-spa-stone font-bold mb-4">
|
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/70 font-bold mb-4">
|
||||||
{getLocalizedText(col.title, lang)}
|
{getLocalizedText(col.title, lang)}
|
||||||
</h4>
|
</h4>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
@@ -90,7 +90,7 @@ export function DesktopMegaMenu({ items }: DesktopMegaMenuProps) {
|
|||||||
<li key={lIdx}>
|
<li key={lIdx}>
|
||||||
<Link
|
<Link
|
||||||
to={link.url || '#'}
|
to={link.url || '#'}
|
||||||
className="text-sm font-normal normal-case tracking-normal text-spa-stone hover:text-spa-forest hover:opacity-80 block transition-colors"
|
className="text-sm font-normal normal-case tracking-normal text-pop-black/80 hover:text-pop-black hover:opacity-80 block transition-colors"
|
||||||
>
|
>
|
||||||
{getLocalizedText(link.label, lang)}
|
{getLocalizedText(link.label, lang)}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -14,15 +14,14 @@ interface DynamicAnnouncementBarProps {
|
|||||||
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
|
export default function DynamicAnnouncementBar({ payload }: DynamicAnnouncementBarProps) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const { backgroundColor = '#1F3D2D', textColor = '#FDFDF9', speed = 3000 } = payload;
|
const { speed = 3000 } = payload;
|
||||||
const slides = (payload.slides || []).map((slide) => getLocalizedField(slide, lang));
|
const slides = (payload.slides || []).map((slide) => getLocalizedField(slide, lang));
|
||||||
|
|
||||||
if (slides.length === 0) return null;
|
if (slides.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="announcement-bar relative z-30 w-full overflow-hidden px-4 py-2.5 text-center text-[10px] font-semibold uppercase tracking-[0.2em] sm:text-xs"
|
className="announcement-bar relative z-30 w-full overflow-hidden px-4 py-2.5 text-center text-[10px] font-semibold uppercase tracking-[0.2em] sm:text-xs bg-pop-black text-pop-bg"
|
||||||
style={{ backgroundColor, color: textColor }}
|
|
||||||
>
|
>
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Autoplay]}
|
modules={[Autoplay]}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
|||||||
const buttonText = getLocalizedField(payload.buttonText, lang);
|
const buttonText = getLocalizedField(payload.buttonText, lang);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band bg-spa-light relative overflow-hidden">
|
<section className="ui-section-band bg-pop-bg relative overflow-hidden">
|
||||||
{linkUrl ? (
|
{linkUrl ? (
|
||||||
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
|
<Link to={linkUrl} className="ui-banner-shell group block cursor-pointer">
|
||||||
<div className="absolute inset-0 z-0">
|
<div className="absolute inset-0 z-0">
|
||||||
@@ -30,12 +30,12 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
|||||||
|
|
||||||
<div className="ui-banner-content">
|
<div className="ui-banner-content">
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="font-serif text-3xl font-light leading-tight text-spa-gold md:text-5xl">
|
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="font-light text-base text-spa-light/95 md:text-lg">
|
<p className="font-light text-base text-pop-bg/95 md:text-lg">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -61,12 +61,12 @@ export default function DynamicBanner({ payload }: DynamicBannerProps) {
|
|||||||
|
|
||||||
<div className="ui-banner-content">
|
<div className="ui-banner-content">
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="font-serif text-3xl font-light leading-tight text-spa-gold md:text-5xl">
|
<h3 className="font-serif text-3xl font-light leading-tight text-pop-pink md:text-5xl">
|
||||||
{title}
|
{title}
|
||||||
</h3>
|
</h3>
|
||||||
)}
|
)}
|
||||||
{description && (
|
{description && (
|
||||||
<p className="font-light text-base text-spa-light/95 md:text-lg">
|
<p className="font-light text-base text-pop-bg/95 md:text-lg">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
|
|||||||
}, [categories, categoryIds]);
|
}, [categories, categoryIds]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section id="categories" className="ui-section-band relative overflow-hidden bg-spa-light">
|
<section id="categories" className="ui-section-band relative overflow-hidden bg-pop-bg">
|
||||||
<div className="ui-section-inner space-y-16 relative z-10">
|
<div className="ui-section-inner space-y-16 relative z-10">
|
||||||
<div className="space-y-4 text-center">
|
<div className="space-y-4 text-center">
|
||||||
<h2 className="text-4xl font-bold tracking-tight text-pop-black md:text-5xl">
|
<h2 className="text-4xl font-bold tracking-tight text-pop-black md:text-5xl">
|
||||||
@@ -40,7 +40,7 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
|
|||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-5">
|
<div className="grid grid-cols-2 gap-6 md:grid-cols-5">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="ui-shell-card h-80 animate-pulse bg-spa-sage" />
|
<div key={i} className="ui-shell-card h-80 animate-pulse bg-pop-black/10" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -51,19 +51,19 @@ export default function DynamicCategories({ payload, categories, loading }: Dyna
|
|||||||
to={`/category/${cat.id}`}
|
to={`/category/${cat.id}`}
|
||||||
className="ui-shell-card ui-shell-card-hover group relative h-80 overflow-hidden"
|
className="ui-shell-card ui-shell-card-hover group relative h-80 overflow-hidden"
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
<div className="absolute inset-0 bg-gradient-to-t from-pop-bg/90 via-pop-bg/40 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
||||||
<img
|
<img
|
||||||
src={cat.imageUrl || getCategoryImage(getLocalizedField(cat.name, lang))}
|
src={cat.imageUrl || getCategoryImage(getLocalizedField(cat.name, lang))}
|
||||||
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="absolute inset-0 w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
alt={getLocalizedField(cat.name, lang)}
|
alt={getLocalizedField(cat.name, lang)}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||||
/>
|
/>
|
||||||
<div className="absolute bottom-6 left-6 right-6 z-20 text-spa-light">
|
<div className="absolute bottom-6 left-6 right-6 z-20 text-pop-black">
|
||||||
<span className="mb-1 block text-xl font-semibold">
|
<span className="mb-1 block text-xl font-semibold">
|
||||||
{getLocalizedField(cat.name, localStorage.getItem('app_lang') || 'en')}
|
{getLocalizedField(cat.name, localStorage.getItem('app_lang') || 'en')}
|
||||||
</span>
|
</span>
|
||||||
{cat._count && (
|
{cat._count && (
|
||||||
<span className="text-sm font-medium text-spa-light">
|
<span className="text-sm font-medium text-pop-black/90">
|
||||||
{cat._count.products} Products
|
{cat._count.products} Products
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
|||||||
const [accent, rest] = part.split(']');
|
const [accent, rest] = part.split(']');
|
||||||
return (
|
return (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
<span className="text-spa-gold font-medium italic">
|
<span className="text-pop-pink font-medium italic">
|
||||||
{accent}
|
{accent}
|
||||||
</span>
|
</span>
|
||||||
{rest}
|
{rest}
|
||||||
@@ -49,10 +49,10 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
|||||||
<section className="ui-dark-shell overflow-hidden">
|
<section className="ui-dark-shell overflow-hidden">
|
||||||
<div className="ui-section-inner space-y-12">
|
<div className="ui-section-inner space-y-12">
|
||||||
<div className="space-y-3 text-center">
|
<div className="space-y-3 text-center">
|
||||||
<h2 className="font-serif text-3xl font-light tracking-wide text-spa-gold md:text-5xl">
|
<h2 className="font-serif text-3xl font-light tracking-wide text-pop-pink md:text-5xl">
|
||||||
{renderTitle(title) || title}
|
{renderTitle(title) || title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="w-16 h-[1px] bg-spa-gold mx-auto"></div>
|
<div className="w-16 h-[1px] bg-pop-pink mx-auto"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative select-none pb-12">
|
<div className="relative select-none pb-12">
|
||||||
@@ -64,13 +64,13 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
|||||||
640: { slidesPerView: 2 },
|
640: { slidesPerView: 2 },
|
||||||
1024: { slidesPerView: 3 }
|
1024: { slidesPerView: 3 }
|
||||||
}}
|
}}
|
||||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-gold' }}
|
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-pink' }}
|
||||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{slides.map((slide, idx) => (
|
{slides.map((slide, idx) => (
|
||||||
<SwiperSlide key={idx} className="h-auto flex flex-col">
|
<SwiperSlide key={idx} className="h-auto flex flex-col">
|
||||||
<div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-spa-gold/30">
|
<div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-pop-pink/30">
|
||||||
<div className="aspect-[4/3] overflow-hidden relative">
|
<div className="aspect-[4/3] overflow-hidden relative">
|
||||||
<img
|
<img
|
||||||
src={slide.imageUrl}
|
src={slide.imageUrl}
|
||||||
@@ -80,18 +80,18 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
|||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-spa-forest/60 via-transparent to-transparent"></div>
|
<div className="absolute inset-0 bg-gradient-to-t from-night-900/80 via-transparent to-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
|
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-spa-gold">
|
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
|
||||||
{slide.category}
|
{slide.category}
|
||||||
</span>
|
</span>
|
||||||
<h3 className="font-serif text-xl font-light tracking-wide text-spa-light">
|
<h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
|
||||||
{renderTitle(slide.title) || slide.title}
|
{renderTitle(slide.title) || slide.title}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="line-clamp-3 text-xs font-light leading-relaxed text-spa-light/70 md:text-sm">
|
<p className="line-clamp-3 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
|
||||||
{slide.description}
|
{slide.description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Link
|
<Link
|
||||||
to={slide.buttonLink}
|
to={slide.buttonLink}
|
||||||
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-spa-gold hover:text-spa-light transition-colors group/link"
|
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
|
||||||
>
|
>
|
||||||
{slide.buttonText}
|
{slide.buttonText}
|
||||||
<span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
<span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||||
|
|||||||
@@ -13,9 +13,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
|||||||
const {
|
const {
|
||||||
imageUrl,
|
imageUrl,
|
||||||
imagePosition = 'left',
|
imagePosition = 'left',
|
||||||
buttonLink,
|
buttonLink
|
||||||
backgroundColor = '#FDFDF9',
|
|
||||||
textColor = '#1F3D2D'
|
|
||||||
} = payload;
|
} = payload;
|
||||||
const title = getLocalizedField(payload.title, lang);
|
const title = getLocalizedField(payload.title, lang);
|
||||||
const description = getLocalizedField(payload.description, lang);
|
const description = getLocalizedField(payload.description, lang);
|
||||||
@@ -28,7 +26,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
|||||||
const [accent, rest] = part.split(']');
|
const [accent, rest] = part.split(']');
|
||||||
return (
|
return (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
<span className="text-spa-gold font-medium italic">
|
<span className="text-pop-pink font-medium italic">
|
||||||
{accent}
|
{accent}
|
||||||
</span>
|
</span>
|
||||||
{rest}
|
{rest}
|
||||||
@@ -41,8 +39,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
className="ui-section-band w-full overflow-hidden"
|
className="ui-section-band w-full overflow-hidden bg-pop-bg text-pop-black"
|
||||||
style={{ backgroundColor, color: textColor }}
|
|
||||||
>
|
>
|
||||||
<div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20">
|
<div className="ui-section-inner grid grid-cols-1 items-center gap-12 md:grid-cols-2 md:gap-20">
|
||||||
<div className={`group relative aspect-square w-full overflow-hidden rounded-3xl shadow-xl ${
|
<div className={`group relative aspect-square w-full overflow-hidden rounded-3xl shadow-xl ${
|
||||||
@@ -65,7 +62,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
|||||||
<h2 className="font-serif text-3xl font-light leading-tight tracking-wide md:text-5xl">
|
<h2 className="font-serif text-3xl font-light leading-tight tracking-wide md:text-5xl">
|
||||||
{renderTitle(title) || title}
|
{renderTitle(title) || title}
|
||||||
</h2>
|
</h2>
|
||||||
<div className="w-12 h-[1px] bg-spa-gold"></div>
|
<div className="w-12 h-[1px] bg-pop-pink"></div>
|
||||||
<p className="whitespace-pre-line text-sm font-light leading-relaxed opacity-90 md:text-base">
|
<p className="whitespace-pre-line text-sm font-light leading-relaxed opacity-90 md:text-base">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
@@ -73,7 +70,7 @@ export default function DynamicEditorialTile({ payload }: DynamicEditorialTilePr
|
|||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<Link
|
<Link
|
||||||
to={buttonLink}
|
to={buttonLink}
|
||||||
className="ui-chip border-current px-8 py-3.5 hover:bg-spa-gold hover:border-spa-gold hover:text-spa-light"
|
className="ui-chip border-current px-8 py-3.5 hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg"
|
||||||
>
|
>
|
||||||
{buttonText}
|
{buttonText}
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
|||||||
const [accent, rest] = part.split(']');
|
const [accent, rest] = part.split(']');
|
||||||
return (
|
return (
|
||||||
<span key={i}>
|
<span key={i}>
|
||||||
<span className="text-spa-gold font-medium italic">
|
<span className="text-pop-pink font-medium italic">
|
||||||
{accent}
|
{accent}
|
||||||
</span>
|
</span>
|
||||||
{rest}
|
{rest}
|
||||||
@@ -50,7 +50,7 @@ export default function DynamicHero({ payload }: DynamicHeroProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-hero-content animate-fade-in">
|
<div className="ui-hero-content animate-fade-in">
|
||||||
<p className="text-spa-sage font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
|
<p className="text-pop-black/70 font-semibold tracking-[0.3em] text-xs md:text-sm uppercase drop-shadow-md">
|
||||||
{tagline || t('hero.defaultTagline')}
|
{tagline || t('hero.defaultTagline')}
|
||||||
</p>
|
</p>
|
||||||
<h1 className="ui-hero-title">
|
<h1 className="ui-hero-title">
|
||||||
|
|||||||
@@ -28,9 +28,9 @@ export default function DynamicHeroArticle({ payload }: DynamicHeroArticleProps)
|
|||||||
<div className="ui-hero-overlay" />
|
<div className="ui-hero-overlay" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="ui-hero-content text-spa-light">
|
<div className="ui-hero-content text-pop-bg">
|
||||||
{category && (
|
{category && (
|
||||||
<span className="mb-4 block text-sm font-bold uppercase tracking-widest text-spa-gold">
|
<span className="mb-4 block text-sm font-bold uppercase tracking-widest text-pop-pink">
|
||||||
{category}
|
{category}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -55,24 +55,24 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
|||||||
return (
|
return (
|
||||||
<section className="ui-dark-shell relative overflow-hidden">
|
<section className="ui-dark-shell relative overflow-hidden">
|
||||||
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
<div className="absolute inset-0 opacity-10 pointer-events-none">
|
||||||
<div className="absolute top-10 left-10 w-96 h-96 rounded-full bg-spa-gold filter blur-3xl"></div>
|
<div className="absolute top-10 left-10 w-96 h-96 rounded-full bg-pop-pink filter blur-3xl"></div>
|
||||||
<div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-spa-sage filter blur-3xl"></div>
|
<div className="absolute bottom-10 right-10 w-96 h-96 rounded-full bg-pop-pink opacity-50 filter blur-3xl"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-section-inner relative z-10 space-y-8 text-center">
|
<div className="ui-section-inner relative z-10 space-y-8 text-center">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h2 className="font-serif text-3xl font-light tracking-wide text-spa-gold md:text-5xl">
|
<h2 className="font-serif text-3xl font-light tracking-wide text-pop-pink md:text-5xl">
|
||||||
{title || t('newsletter.defaultTitle')}
|
{title || t('newsletter.defaultTitle')}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mx-auto max-w-xl text-sm font-light text-spa-light/80 md:text-base">
|
<p className="mx-auto max-w-xl text-sm font-light text-pop-bg/80 md:text-base">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'success' ? (
|
{status === 'success' ? (
|
||||||
<div className="p-6 bg-spa-light border border-spa-gold/30 rounded-2xl animate-pop-in space-y-2">
|
<div className="p-6 bg-pop-bg border border-pop-pink/30 rounded-2xl animate-pop-in space-y-2">
|
||||||
<h4 className="text-spa-gold font-semibold text-lg">{t('newsletter.thankYou')}</h4>
|
<h4 className="text-pop-pink font-semibold text-lg">{t('newsletter.thankYou')}</h4>
|
||||||
<p className="text-xs text-spa-light/75">{t('newsletter.successMessage')}</p>
|
<p className="text-xs text-pop-black/75">{t('newsletter.successMessage')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="mx-auto max-w-lg space-y-4">
|
<form onSubmit={handleSubmit} className="mx-auto max-w-lg space-y-4">
|
||||||
@@ -96,16 +96,16 @@ export default function DynamicNewsletter({ payload }: DynamicNewsletterProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{status === 'error' && (
|
{status === 'error' && (
|
||||||
<p className="text-rose-400 text-xs text-left px-4">{errorMessage}</p>
|
<p className="text-pop-red text-xs text-left px-4">{errorMessage}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showPrivacyCheckbox && (
|
{showPrivacyCheckbox && (
|
||||||
<label className="flex items-start gap-3 text-[11px] text-spa-light/60 text-left px-4 cursor-pointer select-none">
|
<label className="flex items-start gap-3 text-[11px] text-pop-bg/60 text-left px-4 cursor-pointer select-none">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={agreed}
|
checked={agreed}
|
||||||
onChange={(e) => setAgreed(e.target.checked)}
|
onChange={(e) => setAgreed(e.target.checked)}
|
||||||
className="mt-0.5 accent-spa-gold"
|
className="mt-0.5 accent-pop-pink"
|
||||||
/>
|
/>
|
||||||
<span>{privacyText}</span>
|
<span>{privacyText}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import DynamicHeroArticle from './DynamicHeroArticle';
|
|||||||
import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
|
import DynamicScentNotesBreakdown from './DynamicScentNotesBreakdown';
|
||||||
import DynamicQuoteCallout from './DynamicQuoteCallout';
|
import DynamicQuoteCallout from './DynamicQuoteCallout';
|
||||||
import DynamicProductCarousel from './DynamicProductCarousel';
|
import DynamicProductCarousel from './DynamicProductCarousel';
|
||||||
|
import DynamicRichText from './DynamicRichText';
|
||||||
import AccordionSection from './sections/AccordionSection';
|
import AccordionSection from './sections/AccordionSection';
|
||||||
import ContactInfoSection from './sections/ContactInfoSection';
|
import ContactInfoSection from './sections/ContactInfoSection';
|
||||||
import ContactFormSection from './sections/ContactFormSection';
|
import ContactFormSection from './sections/ContactFormSection';
|
||||||
@@ -60,6 +61,8 @@ export default function DynamicPageRenderer({
|
|||||||
return <DynamicBanner key={section.id} payload={section.payload} />;
|
return <DynamicBanner key={section.id} payload={section.payload} />;
|
||||||
case 'TEXT':
|
case 'TEXT':
|
||||||
return <DynamicText key={section.id} payload={section.payload} />;
|
return <DynamicText key={section.id} payload={section.payload} />;
|
||||||
|
case 'RICH_TEXT':
|
||||||
|
return <DynamicRichText key={section.id} section={section} />;
|
||||||
case 'EDITORIAL_TILE':
|
case 'EDITORIAL_TILE':
|
||||||
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
|
return <DynamicEditorialTile key={section.id} payload={section.payload} />;
|
||||||
case 'EDITORIAL_CAROUSEL':
|
case 'EDITORIAL_CAROUSEL':
|
||||||
|
|||||||
@@ -20,21 +20,21 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
|||||||
const description = getLocalizedField(p.description, lang);
|
const description = getLocalizedField(p.description, lang);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-20 bg-spa-sage/30 px-6">
|
<section className="ui-section-band bg-pop-bg">
|
||||||
<div className="max-w-7xl mx-auto">
|
<div className="ui-section-inner">
|
||||||
{(title || description) && (
|
{(title || description) && (
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
{title && <h2 className="text-3xl md:text-5xl font-serif text-spa-forest mb-4">{title}</h2>}
|
{title && <h2 className="text-3xl md:text-5xl font-serif text-pop-black mb-4">{title}</h2>}
|
||||||
{description && <p className="text-lg text-spa-stone/70 max-w-2xl mx-auto">{description}</p>}
|
{description && <p className="text-lg text-pop-black/70 max-w-2xl mx-auto">{description}</p>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div className="text-center py-12 text-spa-stone/50 italic">
|
<div className="text-center py-12 text-pop-black/50 italic">
|
||||||
No products to display.
|
No products to display.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="relative pb-12">
|
<div className="relative pb-12" style={{ '--swiper-navigation-color': 'var(--color-pop-black)' } as React.CSSProperties}>
|
||||||
<Swiper
|
<Swiper
|
||||||
modules={[Autoplay, Pagination, Navigation]}
|
modules={[Autoplay, Pagination, Navigation]}
|
||||||
spaceBetween={32}
|
spaceBetween={32}
|
||||||
@@ -44,7 +44,7 @@ export default function DynamicProductCarousel({ payload }: DynamicProductCarous
|
|||||||
1024: { slidesPerView: 3 },
|
1024: { slidesPerView: 3 },
|
||||||
1280: { slidesPerView: 4 }
|
1280: { slidesPerView: 4 }
|
||||||
}}
|
}}
|
||||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-forest' }}
|
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-black' }}
|
||||||
navigation
|
navigation
|
||||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||||
className="w-full !px-4"
|
className="w-full !px-4"
|
||||||
|
|||||||
@@ -26,18 +26,18 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
return (
|
return (
|
||||||
<section className="ui-section-band relative bg-pop-bg">
|
<section className="ui-section-band relative bg-pop-bg">
|
||||||
<div className="ui-section-inner relative z-10 space-y-12">
|
<div className="ui-section-inner relative z-10 space-y-12">
|
||||||
<div className="flex items-end justify-between gap-6 border-b border-spa-stone/20 pb-6 md:flex-row">
|
<div className="flex flex-col md:flex-row items-start md:items-end justify-between gap-6 border-b border-pop-black/10 pb-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-4xl font-bold text-pop-black md:text-5xl">
|
<h2 className="text-4xl font-bold text-pop-black md:text-5xl">
|
||||||
{title || "Featured Picks"}
|
{title || "Featured Picks"}
|
||||||
</h2>
|
</h2>
|
||||||
{subtitle && (
|
{subtitle && (
|
||||||
<p className="text-spa-stone mt-2">{subtitle}</p>
|
<p className="text-pop-black/60 mt-2">{subtitle}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/shop"
|
to="/shop"
|
||||||
className="text-pop-pink font-medium hover:text-pop-red transition-colors flex items-center gap-2"
|
className="text-pop-pink font-medium hover:opacity-80 transition-opacity flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{t('common.viewAllCollection')}
|
{t('common.viewAllCollection')}
|
||||||
→
|
→
|
||||||
@@ -45,7 +45,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{products.length === 0 ? (
|
{products.length === 0 ? (
|
||||||
<div className="py-12 text-center text-spa-stone">
|
<div className="py-12 text-center text-pop-black/60">
|
||||||
No products found in this section
|
No products found in this section
|
||||||
</div>
|
</div>
|
||||||
) : layout === 'carousel' ? (
|
) : layout === 'carousel' ? (
|
||||||
@@ -58,7 +58,7 @@ export default function DynamicProducts({ payload, categories = [] }: DynamicPro
|
|||||||
640: { slidesPerView: 2 },
|
640: { slidesPerView: 2 },
|
||||||
1024: { slidesPerView: 3 }
|
1024: { slidesPerView: 3 }
|
||||||
}}
|
}}
|
||||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-spa-forest' }}
|
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-pop-black' }}
|
||||||
autoplay={{ delay: 6000, disableOnInteraction: false }}
|
autoplay={{ delay: 6000, disableOnInteraction: false }}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -14,21 +14,23 @@ export default function DynamicQuoteCallout({ payload }: DynamicQuoteCalloutProp
|
|||||||
const role = getLocalizedField(payload.role, lang);
|
const role = getLocalizedField(payload.role, lang);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band bg-spa-light">
|
<section className="ui-section-band bg-pop-bg">
|
||||||
<div className="ui-section-inner text-center">
|
<div className="ui-section-inner text-center">
|
||||||
<svg className="w-12 h-12 mx-auto text-spa-gold/40 mb-8" fill="currentColor" viewBox="0 0 24 24">
|
<div className="bg-pop-black/5 rounded-3xl p-8 max-w-4xl mx-auto">
|
||||||
|
<svg className="w-12 h-12 mx-auto text-pop-black/40 mb-8" fill="currentColor" viewBox="0 0 24 24">
|
||||||
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
<path d="M14.017 21v-7.391c0-5.704 3.731-9.57 8.983-10.609l.995 2.151c-2.432.917-3.995 3.638-3.995 5.849h4v10h-9.983zm-14.017 0v-7.391c0-5.704 3.748-9.57 9-10.609l.996 2.151c-2.433.917-3.996 3.638-3.996 5.849h3.983v10h-9.983z" />
|
||||||
</svg>
|
</svg>
|
||||||
<blockquote className="mb-8 font-serif text-2xl italic leading-relaxed text-spa-forest md:text-4xl">
|
<blockquote className="mb-8 font-serif text-2xl italic leading-relaxed text-pop-black md:text-4xl">
|
||||||
"{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}"
|
"{quote || 'Fragrance is the invisible, unforgettable, ultimate accessory of fashion.'}"
|
||||||
</blockquote>
|
</blockquote>
|
||||||
{(author || role) && (
|
{(author || role) && (
|
||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
{author && <span className="font-bold uppercase tracking-widest text-spa-stone">{author}</span>}
|
{author && <span className="font-bold uppercase tracking-widest text-pop-black/70">{author}</span>}
|
||||||
{role && <span className="text-sm text-spa-stone/60 mt-1 uppercase tracking-wide">{role}</span>}
|
{role && <span className="text-sm text-pop-black/60 mt-1 uppercase tracking-wide">{role}</span>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { PageSection } from '@homaroma/types';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { getLocalizedField } from '../utils/localization';
|
||||||
|
|
||||||
|
interface DynamicRichTextProps {
|
||||||
|
section: PageSection;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DynamicRichText({ section }: DynamicRichTextProps) {
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const lang = i18n.language;
|
||||||
|
const payload = section.payload || {};
|
||||||
|
|
||||||
|
const title = getLocalizedField(payload.title, lang);
|
||||||
|
const content = getLocalizedField(payload.content, lang);
|
||||||
|
|
||||||
|
if (!content) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ui-section-band bg-pop-bg relative">
|
||||||
|
<div className="ui-section-inner space-y-6">
|
||||||
|
{title && (
|
||||||
|
<h2 className="font-serif text-3xl font-semibold text-pop-black md:text-4xl text-center">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className="ui-prose text-base font-light leading-relaxed text-pop-black/80 md:text-lg max-w-3xl mx-auto"
|
||||||
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -28,35 +28,35 @@ export default function DynamicScentNotesBreakdown({ payload }: DynamicScentNote
|
|||||||
const baseNotes = notes(payload.baseNotes);
|
const baseNotes = notes(payload.baseNotes);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band bg-spa-light">
|
<section className="ui-section-band bg-pop-bg">
|
||||||
<div className="ui-section-inner text-center">
|
<div className="ui-section-inner text-center">
|
||||||
<h2 className="mb-6 font-serif text-3xl text-spa-forest md:text-5xl">
|
<h2 className="mb-6 font-serif text-3xl text-pop-black md:text-5xl">
|
||||||
{title || 'Scent Notes'}
|
{title || 'Scent Notes'}
|
||||||
</h2>
|
</h2>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="ui-section-subtitle mb-16 text-lg text-spa-stone/80">
|
<p className="ui-section-subtitle mb-16 text-lg text-pop-black/80">
|
||||||
{description}
|
{description}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
||||||
<div className="ui-shell-card p-8">
|
<div className="ui-shell-card p-8 hover:-translate-y-1 hover:shadow-lg transition-all duration-300">
|
||||||
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.top}</h3>
|
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-pop-black">{labelSet.top}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-pop-black/80 font-light leading-relaxed">
|
||||||
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
{topNotes || 'The initial, lighter smells of the fragrance.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-shell-card p-8">
|
<div className="ui-shell-card p-8 hover:-translate-y-1 hover:shadow-lg transition-all duration-300">
|
||||||
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.heart}</h3>
|
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-pop-black">{labelSet.heart}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-pop-black/80 font-light leading-relaxed">
|
||||||
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
{heartNotes || 'The core of the fragrance, emerging just after the top notes.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ui-shell-card p-8">
|
<div className="ui-shell-card p-8 hover:-translate-y-1 hover:shadow-lg transition-all duration-300">
|
||||||
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-spa-forest">{labelSet.base}</h3>
|
<h3 className="mb-4 text-xl font-bold uppercase tracking-widest text-pop-black">{labelSet.base}</h3>
|
||||||
<p className="text-spa-stone font-light leading-relaxed">
|
<p className="text-pop-black/80 font-light leading-relaxed">
|
||||||
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
{baseNotes || 'The deepest, richest scents that linger the longest.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ export default function DynamicText({ payload }: DynamicTextProps) {
|
|||||||
}[textAlign] || 'text-left';
|
}[textAlign] || 'text-left';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band bg-spa-light relative">
|
<section className="ui-section-band bg-pop-bg relative">
|
||||||
<div className={`ui-section-inner space-y-6 ${alignClass}`}>
|
<div className={`ui-section-inner space-y-6 ${alignClass}`}>
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className="font-serif text-3xl font-semibold text-spa-forest md:text-4xl">
|
<h2 className="font-serif text-3xl font-semibold text-pop-black md:text-4xl">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className="whitespace-pre-wrap text-base font-light leading-relaxed text-spa-stone md:text-lg"
|
className="ui-prose text-base font-light leading-relaxed text-pop-black/80 md:text-lg"
|
||||||
dangerouslySetInnerHTML={{ __html: content }}
|
dangerouslySetInnerHTML={{ __html: content }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -17,18 +17,18 @@ export default function EmptyState({
|
|||||||
actionClick
|
actionClick
|
||||||
}: EmptyStateProps) {
|
}: EmptyStateProps) {
|
||||||
const ButtonContent = () => (
|
const ButtonContent = () => (
|
||||||
<span className="inline-block mt-6 px-8 py-3 bg-spa-forest text-spa-light rounded-xl text-sm font-bold uppercase tracking-widest hover:bg-spa-stone hover:shadow-lg transition-all cursor-pointer">
|
<span className="inline-block mt-6 px-8 py-3 bg-pop-black text-pop-bg rounded-xl text-sm font-bold uppercase tracking-widest hover:bg-pop-pink hover:text-pop-bg hover:shadow-lg transition-all cursor-pointer">
|
||||||
{actionText}
|
{actionText}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center p-12 bg-spa-light border border-spa-stone/10 rounded-2xl shadow-sm max-w-lg mx-auto my-8">
|
<div className="flex flex-col items-center justify-center space-y-4 text-center p-12 bg-pop-bg border border-pop-black/10 rounded-2xl shadow-sm max-w-lg mx-auto my-8">
|
||||||
<div className="w-16 h-16 bg-spa-sage/30 rounded-full flex items-center justify-center mb-2">
|
<div className="w-16 h-16 bg-pop-black/5 rounded-full flex items-center justify-center mb-2">
|
||||||
<PackageOpen className="w-8 h-8 text-spa-forest" />
|
<PackageOpen className="w-8 h-8 text-pop-black" />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-xl font-bold text-pop-black">{title}</h3>
|
<h3 className="text-xl font-bold text-pop-black">{title}</h3>
|
||||||
<p className="text-spa-stone">{message}</p>
|
<p className="text-pop-black/70">{message}</p>
|
||||||
|
|
||||||
{actionClick ? (
|
{actionClick ? (
|
||||||
<div onClick={actionClick}><ButtonContent /></div>
|
<div onClick={actionClick}><ButtonContent /></div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function ErrorState({
|
|||||||
showHomeButton = true
|
showHomeButton = true
|
||||||
}: ErrorStateProps) {
|
}: ErrorStateProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center p-8 bg-spa-light border border-rose-500/20 rounded-2xl shadow-sm max-w-md mx-auto">
|
<div className="flex flex-col items-center justify-center space-y-4 text-center p-8 bg-pop-bg border border-rose-500/20 rounded-2xl shadow-sm max-w-md mx-auto">
|
||||||
<div className="w-12 h-12 bg-rose-500/10 rounded-full flex items-center justify-center">
|
<div className="w-12 h-12 bg-rose-500/10 rounded-full flex items-center justify-center">
|
||||||
<AlertCircle className="w-6 h-6 text-rose-500" />
|
<AlertCircle className="w-6 h-6 text-rose-500" />
|
||||||
</div>
|
</div>
|
||||||
@@ -21,7 +21,7 @@ export default function ErrorState({
|
|||||||
{showHomeButton && (
|
{showHomeButton && (
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="mt-4 px-6 py-2 bg-spa-forest text-spa-light rounded-lg text-sm font-bold uppercase tracking-widest hover:bg-spa-stone transition-colors"
|
className="mt-4 px-6 py-2 bg-pop-black text-pop-bg rounded-lg text-sm font-bold uppercase tracking-widest hover:bg-pop-black/80 transition-colors"
|
||||||
>
|
>
|
||||||
Return Home
|
Return Home
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -71,31 +71,31 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<footer className="bg-night-900 px-6 py-20 border-t border-spa-light">
|
<footer className="bg-night-900 text-pop-black px-6 py-20 border-t border-pop-black/20">
|
||||||
<div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4">
|
<div className="ui-section-inner grid grid-cols-1 gap-12 md:grid-cols-4">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-2xl font-bold tracking-widest">
|
<div className="text-2xl font-bold tracking-widest text-pop-black">
|
||||||
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
{settings?.logoUrl ? <img src={settings.logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm leading-relaxed text-spa-stone">
|
<p className="text-sm leading-relaxed text-pop-black/60">
|
||||||
{settings?.footerDescription || t('footer.catchphrase')}
|
{settings?.footerDescription || t('footer.catchphrase')}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
|
{settings && (settings.facebookUrl || settings.instagramUrl || settings.tiktokUrl) ? (
|
||||||
<>
|
<>
|
||||||
{settings.facebookUrl && (
|
{settings.facebookUrl && (
|
||||||
<a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-spa-light bg-spa-light text-[10px] text-spa-light hover:bg-spa-gold hover:text-night-900">FB</a>
|
<a href={settings.facebookUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">FB</a>
|
||||||
)}
|
)}
|
||||||
{settings.instagramUrl && (
|
{settings.instagramUrl && (
|
||||||
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-spa-light bg-spa-light text-[10px] text-spa-light hover:bg-spa-gold hover:text-night-900">IG</a>
|
<a href={settings.instagramUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">IG</a>
|
||||||
)}
|
)}
|
||||||
{settings.tiktokUrl && (
|
{settings.tiktokUrl && (
|
||||||
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-spa-light bg-spa-light text-[10px] text-spa-light hover:bg-spa-gold hover:text-night-900">TT</a>
|
<a href={settings.tiktokUrl} target="_blank" rel="noreferrer" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">TT</a>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
['IG', 'FB', 'TW', 'YT'].map((s) => (
|
['IG', 'FB', 'TW', 'YT'].map((s) => (
|
||||||
<a key={s} href="#" className="ui-chip h-9 w-9 border-spa-light bg-spa-light text-[10px] text-spa-light hover:bg-spa-gold hover:text-night-900">
|
<a key={s} href="#" className="ui-chip h-9 w-9 border-pop-black/20 bg-transparent text-[10px] text-pop-black hover:bg-pop-pink hover:border-pop-pink hover:text-pop-bg">
|
||||||
{s}
|
{s}
|
||||||
</a>
|
</a>
|
||||||
))
|
))
|
||||||
@@ -104,30 +104,30 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-spa-light">{t('footer.explore')}</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('footer.explore')}</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-spa-stone">
|
<div className="flex flex-col space-y-2 text-sm text-pop-black/60">
|
||||||
{exploreMenu.length > 0 ? (
|
{exploreMenu.length > 0 ? (
|
||||||
exploreMenu.map(item => (
|
exploreMenu.map(item => (
|
||||||
<Link key={item.id} to={item.url} className="hover:text-spa-light transition-colors">{translateMenuLabel(item)}</Link>
|
<Link key={item.id} to={item.url} className="hover:text-pop-pink transition-colors">{translateMenuLabel(item)}</Link>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Link to="/" className="hover:text-spa-light transition-colors">{t('nav.home')}</Link>
|
<Link to="/" className="hover:text-pop-pink transition-colors">{t('nav.home')}</Link>
|
||||||
<Link to="/shop" className="hover:text-spa-light transition-colors">{t('nav.shop')}</Link>
|
<Link to="/shop" className="hover:text-pop-pink transition-colors">{t('nav.shop')}</Link>
|
||||||
<Link to="/scent-guide" className="hover:text-spa-light transition-colors">{t('nav.scentGuide')}</Link>
|
<Link to="/scent-guide" className="hover:text-pop-pink transition-colors">{t('nav.scentGuide')}</Link>
|
||||||
<Link to="/about" className="hover:text-spa-light transition-colors">{t('nav.about')}</Link>
|
<Link to="/about" className="hover:text-pop-pink transition-colors">{t('nav.about')}</Link>
|
||||||
<Link to="/contact" className="hover:text-spa-light transition-colors">{t('nav.contact')}</Link>
|
<Link to="/contact" className="hover:text-pop-pink transition-colors">{t('nav.contact')}</Link>
|
||||||
<Link to="/faq" className="hover:text-spa-light transition-colors">{t('nav.faq')}</Link>
|
<Link to="/faq" className="hover:text-pop-pink transition-colors">{t('nav.faq')}</Link>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-spa-light">{t('nav.collections')}</h4>
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">{t('nav.collections')}</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-spa-stone">
|
<div className="flex flex-col space-y-2 text-sm text-pop-black/60">
|
||||||
{categories.map((c) => (
|
{categories.map((c) => (
|
||||||
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-spa-light transition-colors">
|
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-pop-pink transition-colors">
|
||||||
{getLocalizedField(c.name, localStorage.getItem('app_lang') || 'en')}
|
{getLocalizedField(c.name, localStorage.getItem('app_lang') || 'en')}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -135,17 +135,17 @@ export default function Footer({ categories = [] }: FooterProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h4 className="text-xs font-bold uppercase tracking-widest text-spa-light">
|
<h4 className="text-xs font-bold uppercase tracking-widest text-pop-black">
|
||||||
{settings?.footerSupportHeading || t('footer.support', 'Support & Legal')}
|
{settings?.footerSupportHeading || t('footer.support', 'Support & Legal')}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex flex-col space-y-2 text-sm text-spa-stone">
|
<div className="flex flex-col space-y-2 text-sm text-pop-black/60">
|
||||||
{supportMenu.map(item => (
|
{supportMenu.map(item => (
|
||||||
<Link key={item.id} to={item.url} className="hover:text-spa-light transition-colors">{translateMenuLabel(item)}</Link>
|
<Link key={item.id} to={item.url} className="hover:text-pop-pink transition-colors">{translateMenuLabel(item)}</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4">
|
<div className="pt-4">
|
||||||
<p className="text-[10px] text-spa-stone uppercase tracking-widest">
|
<p className="text-[10px] text-pop-black/40 uppercase tracking-widest">
|
||||||
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
|
{settings?.footerCopyrightText || `© ${new Date().getFullYear()} ${brandName}. ${t('footer.rights')}`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ interface LoadingStateProps {
|
|||||||
|
|
||||||
export default function LoadingState({ message = 'Loading...', fullScreen = false }: LoadingStateProps) {
|
export default function LoadingState({ message = 'Loading...', fullScreen = false }: LoadingStateProps) {
|
||||||
const content = (
|
const content = (
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-spa-stone p-8">
|
<div className="flex flex-col items-center justify-center space-y-4 text-pop-black/70 p-8">
|
||||||
<Loader2 className="w-8 h-8 animate-spin text-spa-forest" />
|
<Loader2 className="w-8 h-8 animate-spin text-pop-pink" />
|
||||||
<p className="text-sm font-semibold tracking-widest uppercase">{message}</p>
|
<p className="text-sm font-semibold tracking-widest uppercase">{message}</p>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,19 +62,19 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
return (
|
return (
|
||||||
<div className="px-6 py-2 space-y-1">
|
<div className="px-6 py-2 space-y-1">
|
||||||
{view.data.map((item, index) => (
|
{view.data.map((item, index) => (
|
||||||
<div key={item.id || `menu-root-${index}`} className="border-b border-spa-stone/10 last:border-0">
|
<div key={item.id || `menu-root-${index}`} className="border-b border-pop-black/10 last:border-0">
|
||||||
{item.columns && item.columns.length > 0 ? (
|
{item.columns && item.columns.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => pushView({ type: 'mega', data: item.columns!, title: item.label })}
|
onClick={() => pushView({ type: 'mega', data: item.columns!, title: item.label })}
|
||||||
className="w-full flex items-center justify-between py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase group"
|
className="w-full flex items-center justify-between py-4 text-sm font-medium tracking-wide text-pop-black/70 hover:text-pop-black uppercase group"
|
||||||
>
|
>
|
||||||
<span>{getLocalizedText(item.label, lang)}</span>
|
<span>{getLocalizedText(item.label, lang)}</span>
|
||||||
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
<ChevronRight className="w-4 h-4 text-pop-black/40 group-hover:text-pop-black transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLinkClick(item.url || '#')}
|
onClick={() => handleLinkClick(item.url || '#')}
|
||||||
className="w-full text-left py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase"
|
className="w-full text-left py-4 text-sm font-medium tracking-wide text-pop-black/70 hover:text-pop-black uppercase"
|
||||||
>
|
>
|
||||||
{getLocalizedText(item.label, lang)}
|
{getLocalizedText(item.label, lang)}
|
||||||
</button>
|
</button>
|
||||||
@@ -82,10 +82,10 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{customer && (
|
{customer && (
|
||||||
<div className="border-b border-spa-stone/10">
|
<div className="border-b border-pop-black/10">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLinkClick('/profile')}
|
onClick={() => handleLinkClick('/profile')}
|
||||||
className="w-full text-left py-4 text-sm font-medium tracking-wide text-spa-stone hover:text-spa-forest uppercase"
|
className="w-full text-left py-4 text-sm font-medium tracking-wide text-pop-black/70 hover:text-pop-black uppercase"
|
||||||
>
|
>
|
||||||
👤 My Profile
|
👤 My Profile
|
||||||
</button>
|
</button>
|
||||||
@@ -100,7 +100,7 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
<div className="px-6 py-4">
|
<div className="px-6 py-4">
|
||||||
{view.data.map((col, idx) => (
|
{view.data.map((col, idx) => (
|
||||||
<div key={idx} className="mb-8 last:mb-0">
|
<div key={idx} className="mb-8 last:mb-0">
|
||||||
<h4 className="text-[10px] uppercase tracking-widest text-spa-stone/60 font-bold mb-4">
|
<h4 className="text-[10px] uppercase tracking-widest text-pop-black/60 font-bold mb-4">
|
||||||
{getLocalizedText(col.title, lang)}
|
{getLocalizedText(col.title, lang)}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
@@ -109,15 +109,15 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
{link.links && link.links.length > 0 ? (
|
{link.links && link.links.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
||||||
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest group"
|
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black group"
|
||||||
>
|
>
|
||||||
<span>{getLocalizedText(link.label, lang)}</span>
|
<span>{getLocalizedText(link.label, lang)}</span>
|
||||||
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
<ChevronRight className="w-4 h-4 text-pop-black/40 group-hover:text-pop-black transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLinkClick(link.url || '#')}
|
onClick={() => handleLinkClick(link.url || '#')}
|
||||||
className="w-full text-left py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest"
|
className="w-full text-left py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black"
|
||||||
>
|
>
|
||||||
{getLocalizedText(link.label, lang)}
|
{getLocalizedText(link.label, lang)}
|
||||||
</button>
|
</button>
|
||||||
@@ -139,15 +139,15 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
{link.links && link.links.length > 0 ? (
|
{link.links && link.links.length > 0 ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
onClick={() => pushView({ type: 'links', data: link.links!, title: link.label })}
|
||||||
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest group"
|
className="w-full flex items-center justify-between py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black group"
|
||||||
>
|
>
|
||||||
<span>{getLocalizedText(link.label, lang)}</span>
|
<span>{getLocalizedText(link.label, lang)}</span>
|
||||||
<ChevronRight className="w-4 h-4 text-spa-stone/40 group-hover:text-spa-forest transition-colors" />
|
<ChevronRight className="w-4 h-4 text-pop-black/40 group-hover:text-pop-black transition-colors" />
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLinkClick(link.url || '#')}
|
onClick={() => handleLinkClick(link.url || '#')}
|
||||||
className="w-full text-left py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest"
|
className="w-full text-left py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black"
|
||||||
>
|
>
|
||||||
{getLocalizedText(link.label, lang)}
|
{getLocalizedText(link.label, lang)}
|
||||||
</button>
|
</button>
|
||||||
@@ -164,28 +164,28 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
return (
|
return (
|
||||||
<div className={`fixed inset-0 z-[60] md:hidden transition-all duration-300 ${isOpen ? 'visible' : 'invisible'}`}>
|
<div className={`fixed inset-0 z-[60] md:hidden transition-all duration-300 ${isOpen ? 'visible' : 'invisible'}`}>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute inset-0 bg-pop-black/40 backdrop-blur-sm transition-opacity duration-300 ${isOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute top-0 right-0 h-full w-80 max-w-[85vw] bg-spa-light shadow-2xl flex flex-col transition-transform duration-300 ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
className={`absolute top-0 right-0 h-full w-80 max-w-[85vw] bg-pop-bg shadow-2xl flex flex-col transition-transform duration-300 ${isOpen ? 'translate-x-0' : 'translate-x-full'}`}
|
||||||
>
|
>
|
||||||
{/* Static Top Header */}
|
{/* Static Top Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-5 border-b border-spa-stone/10 bg-spa-light z-10 shrink-0">
|
<div className="flex items-center justify-between px-6 py-5 border-b border-pop-black/10 bg-pop-bg z-10 shrink-0">
|
||||||
<span className="text-sm font-bold uppercase tracking-widest text-spa-forest">Menu</span>
|
<span className="text-sm font-bold uppercase tracking-widest text-pop-black">Menu</span>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button onClick={onOpenLang} 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">
|
<button onClick={onOpenLang} className="px-2.5 py-1 rounded-md text-[10px] font-bold text-pop-black border border-pop-black/20 uppercase bg-pop-black/5 flex items-center gap-1 shrink-0">
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
{i18n.language.substring(0, 2).toUpperCase()}
|
{i18n.language.substring(0, 2).toUpperCase()}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={onClose} className="p-1.5 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
|
<button onClick={onClose} className="p-1.5 rounded-full hover:bg-pop-black/5 transition-all text-pop-black/70">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sliding Views Container */}
|
{/* Sliding Views Container */}
|
||||||
<div className="flex-1 relative overflow-hidden bg-spa-light">
|
<div className="flex-1 relative overflow-hidden bg-pop-bg">
|
||||||
{stack.map((view, index) => {
|
{stack.map((view, index) => {
|
||||||
const isCurrent = index === stack.length - 1;
|
const isCurrent = index === stack.length - 1;
|
||||||
const isPast = index < stack.length - 1;
|
const isPast = index < stack.length - 1;
|
||||||
@@ -193,7 +193,7 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={index}
|
key={index}
|
||||||
className={`absolute inset-0 flex flex-col bg-spa-light transition-transform duration-300 ease-in-out ${
|
className={`absolute inset-0 flex flex-col bg-pop-bg transition-transform duration-300 ease-in-out ${
|
||||||
isCurrent ? 'translate-x-0' : isPast ? '-translate-x-full' : 'translate-x-full'
|
isCurrent ? 'translate-x-0' : isPast ? '-translate-x-full' : 'translate-x-full'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
@@ -201,7 +201,7 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={popView}
|
onClick={popView}
|
||||||
className="flex items-center px-6 py-4 text-xs font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/5 hover:bg-spa-sage transition-colors shrink-0"
|
className="flex items-center px-6 py-4 text-xs font-bold uppercase tracking-widest text-pop-black border-b border-pop-black/5 hover:bg-pop-black/5 transition-colors shrink-0"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-4 h-4 mr-2" />
|
<ChevronLeft className="w-4 h-4 mr-2" />
|
||||||
Back to {getLocalizedText(stack[index - 1].title, lang)}
|
Back to {getLocalizedText(stack[index - 1].title, lang)}
|
||||||
@@ -211,7 +211,7 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
{/* Optional View Title Header for nested views to emphasize context */}
|
{/* Optional View Title Header for nested views to emphasize context */}
|
||||||
{index > 0 && (
|
{index > 0 && (
|
||||||
<div className="px-6 py-5 shrink-0">
|
<div className="px-6 py-5 shrink-0">
|
||||||
<h3 className="text-lg font-bold uppercase tracking-widest text-spa-forest">{getLocalizedText(view.title, lang)}</h3>
|
<h3 className="text-lg font-bold uppercase tracking-widest text-pop-black">{getLocalizedText(view.title, lang)}</h3>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -225,10 +225,10 @@ export function MobileDrawer({ isOpen, onClose, items, onOpenLang }: MobileDrawe
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Static Bottom Footer */}
|
{/* Static Bottom Footer */}
|
||||||
<div className="px-6 pb-8 pt-6 border-t border-spa-stone/10 bg-spa-light z-10 shrink-0 space-y-3">
|
<div className="px-6 pb-8 pt-6 border-t border-pop-black/10 bg-pop-bg z-10 shrink-0 space-y-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleLinkClick('/shop')}
|
onClick={() => handleLinkClick('/shop')}
|
||||||
className="block w-full py-3.5 bg-spa-forest text-spa-light font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-spa-stone transition-colors duration-300 shadow-sm"
|
className="block w-full py-3.5 bg-pop-black text-pop-bg font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-pop-black/80 transition-colors duration-300 shadow-sm"
|
||||||
>
|
>
|
||||||
Shop Now
|
Shop Now
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -151,16 +151,16 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
<>
|
<>
|
||||||
<nav
|
<nav
|
||||||
ref={navRef}
|
ref={navRef}
|
||||||
className={`fixed ${finalTopClassName} z-50 w-full border-b border-black/5 transition-all duration-300 ${
|
className={`fixed ${finalTopClassName} z-50 w-full border-b border-pop-black/10 transition-all duration-300 ${
|
||||||
scrolled ? 'shadow-lg shadow-black/5' : 'shadow-none'
|
scrolled ? 'shadow-lg shadow-pop-black/10 backdrop-blur-xl bg-pop-bg/90' : 'shadow-none bg-pop-bg'
|
||||||
} bg-spa-light px-4 py-3.5 backdrop-blur-xl md:bg-spa-sage md:px-6 md:py-4 md:backdrop-blur-none`}
|
} px-4 py-3.5 md:px-6 md:py-4`}
|
||||||
>
|
>
|
||||||
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
|
{/* Single-row layout: Logo (left) | Menu (center, desktop) | Actions (right) */}
|
||||||
<div className="w-full flex items-center justify-between gap-4">
|
<div className="w-full flex items-center justify-between gap-4">
|
||||||
|
|
||||||
{/* Left: Logo */}
|
{/* Left: Logo */}
|
||||||
<div className="flex items-center shrink-0">
|
<div className="flex items-center shrink-0">
|
||||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-spa-forest uppercase shrink-0 md:text-2xl">
|
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl font-bold italic tracking-widest text-pop-black uppercase shrink-0 md:text-2xl">
|
||||||
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
{logoUrl ? <img src={logoUrl} alt={brandName} className="h-8 md:h-10 object-contain" /> : brandName}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -177,10 +177,10 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
{/* Search — desktop & mobile */}
|
{/* Search — desktop & mobile */}
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
onClick={() => { setSearchOpen(true); setMobileOpen(false); }}
|
||||||
className="group rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
|
className="group rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||||
aria-label="Search"
|
aria-label="Search"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4 group-hover:text-spa-forest transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4 group-hover:text-pop-black transition-colors" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
@@ -188,12 +188,12 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
<Link
|
<Link
|
||||||
to="/wishlist"
|
to="/wishlist"
|
||||||
onClick={() => setMobileOpen(false)}
|
onClick={() => setMobileOpen(false)}
|
||||||
className="group relative rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
|
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||||
aria-label="Wishlist"
|
aria-label="Wishlist"
|
||||||
>
|
>
|
||||||
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
<Heart className="w-4 h-4 group-hover:text-rose-500 transition-colors" />
|
||||||
{wishlistCount > 0 && (
|
{wishlistCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-spa-light text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||||
{wishlistCount}
|
{wishlistCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -201,12 +201,12 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
onClick={() => { toggleCart(true); setMobileOpen(false); }}
|
||||||
className="group relative rounded-full p-1.5 text-spa-stone transition-all hover:bg-spa-sage md:p-2"
|
className="group relative rounded-full p-1.5 text-pop-black/80 transition-all hover:bg-pop-black/5 md:p-2"
|
||||||
aria-label="Cart"
|
aria-label="Cart"
|
||||||
>
|
>
|
||||||
<ShoppingBag className="w-4 h-4 group-hover:text-spa-forest transition-colors" />
|
<ShoppingBag className="w-4 h-4 group-hover:text-pop-black transition-colors" />
|
||||||
{cartCount > 0 && (
|
{cartCount > 0 && (
|
||||||
<span className="absolute -top-1 -right-1 bg-rose-500 text-spa-light text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
<span className="absolute -top-1 -right-1 bg-rose-500 text-white text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||||
{cartCount}
|
{cartCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -215,19 +215,19 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
{isDesktop && (
|
{isDesktop && (
|
||||||
<>
|
<>
|
||||||
<div className="relative group flex items-center">
|
<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-spa-light flex items-center gap-1.5 uppercase tracking-wider">
|
<button className="px-3 py-2 rounded-full text-[10px] font-bold transition-all text-pop-black border border-pop-black/20 hover:bg-pop-black/5 bg-pop-bg flex items-center gap-1.5 uppercase tracking-wider">
|
||||||
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
<Globe className="w-3.5 h-3.5" strokeWidth={1.5} />
|
||||||
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
{LANGUAGES.find(l => i18n.language.startsWith(l.code))?.short || 'TH'}
|
||||||
</button>
|
</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="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-spa-light backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-2xl p-1.5 space-y-0.5">
|
||||||
{LANGUAGES.map(lang => {
|
{LANGUAGES.map(lang => {
|
||||||
const isActive = i18n.language.startsWith(lang.code)
|
const isActive = i18n.language.startsWith(lang.code)
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => changeLanguage(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'}`}
|
className={`w-full flex items-center justify-between px-3 py-2.5 text-xs rounded-lg transition-colors font-medium ${isActive ? 'text-pop-black bg-pop-black/10 font-bold' : 'text-pop-black/70 hover:bg-pop-black/5 hover:text-pop-black'}`}
|
||||||
>
|
>
|
||||||
<span>{lang.label}</span>
|
<span>{lang.label}</span>
|
||||||
{isActive && <Check className="w-3.5 h-3.5" />}
|
{isActive && <Check className="w-3.5 h-3.5" />}
|
||||||
@@ -244,18 +244,18 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
<img
|
<img
|
||||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||||
alt="Profile"
|
alt="Profile"
|
||||||
className="w-9 h-9 rounded-full border-2 border-spa-forest/30 cursor-pointer object-cover animate-fade-in"
|
className="w-9 h-9 rounded-full border-2 border-pop-black/20 cursor-pointer object-cover animate-fade-in"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<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="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-spa-light backdrop-blur-xl border border-spa-stone/10 rounded-xl overflow-hidden shadow-xl p-1">
|
<div className="bg-pop-bg/95 backdrop-blur-xl border border-pop-black/10 rounded-xl overflow-hidden shadow-xl p-1">
|
||||||
<div className="px-3 py-2 border-b border-spa-stone/10 mb-1">
|
<div className="px-3 py-2 border-b border-pop-black/10 mb-1">
|
||||||
<p className="text-[10px] text-spa-stone/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
<p className="text-[10px] text-pop-black/60 uppercase tracking-widest leading-tight truncate">{t('nav.signedInAs', 'Signed in as')}</p>
|
||||||
<p className="text-xs text-spa-forest font-bold truncate">{customer.displayName}</p>
|
<p className="text-xs text-pop-black font-bold truncate">{customer.displayName}</p>
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
to="/profile"
|
to="/profile"
|
||||||
className="block px-3 py-2 text-xs uppercase tracking-widest text-spa-stone hover:text-spa-forest hover:bg-spa-light rounded-lg transition-colors font-bold"
|
className="block px-3 py-2 text-xs uppercase tracking-widest text-pop-black/70 hover:text-pop-black hover:bg-pop-black/5 rounded-lg transition-colors font-bold"
|
||||||
>
|
>
|
||||||
My Profile
|
My Profile
|
||||||
</Link>
|
</Link>
|
||||||
@@ -271,7 +271,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={loginLiff}
|
onClick={loginLiff}
|
||||||
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-spa-light hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
className="ml-2 px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest bg-[#00B900] text-white hover:bg-[#009900] transition-all duration-300 shadow-sm"
|
||||||
>
|
>
|
||||||
{t('nav.login', 'LINE Login')}
|
{t('nav.login', 'LINE Login')}
|
||||||
</button>
|
</button>
|
||||||
@@ -282,7 +282,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
{!isDesktop && (
|
{!isDesktop && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setMobileOpen(prev => !prev)}
|
onClick={() => setMobileOpen(prev => !prev)}
|
||||||
className="p-1.5 rounded-full text-spa-stone hover:bg-spa-sage transition-all"
|
className="p-1.5 rounded-full text-pop-black/80 hover:bg-pop-black/5 transition-all"
|
||||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||||
>
|
>
|
||||||
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
{mobileOpen ? <X className="w-5 h-5" /> : <MenuIcon className="w-5 h-5" />}
|
||||||
@@ -303,13 +303,13 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
|
|
||||||
{searchOpen && (
|
{searchOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[100] bg-spa-light/97 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
|
className="fixed inset-0 z-[100] bg-pop-bg/95 backdrop-blur-xl flex flex-col items-center pt-24 md:pt-32 px-4 md:px-6"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) setSearchOpen(false) }}
|
onClick={(e) => { if (e.target === e.currentTarget) setSearchOpen(false) }}
|
||||||
>
|
>
|
||||||
<div className="w-full max-w-2xl space-y-6 animate-fade-in">
|
<div className="w-full max-w-2xl space-y-6 animate-fade-in">
|
||||||
<div className="flex justify-between items-center">
|
<div className="flex justify-between items-center">
|
||||||
<p className="text-spa-stone text-xs uppercase tracking-widest font-semibold">{t('nav.search.title')}</p>
|
<p className="text-pop-black/70 text-xs uppercase tracking-widest font-semibold">{t('nav.search.title')}</p>
|
||||||
<button onClick={() => setSearchOpen(false)} className="p-2 rounded-full hover:bg-spa-sage transition-all text-spa-stone">
|
<button onClick={() => setSearchOpen(false)} className="p-2 rounded-full hover:bg-pop-black/5 transition-all text-pop-black/80">
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -317,8 +317,8 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSearch}>
|
<form onSubmit={handleSearch}>
|
||||||
<div className="flex bg-spa-light border-2 border-spa-stone/20 focus-within:border-spa-forest rounded-2xl overflow-hidden transition-colors shadow-sm">
|
<div className="flex bg-pop-bg border-2 border-pop-black/20 focus-within:border-pop-black rounded-2xl overflow-hidden transition-colors shadow-sm">
|
||||||
<svg className="w-5 h-5 text-spa-stone/50 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5 text-pop-black/50 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||||
</svg>
|
</svg>
|
||||||
<input
|
<input
|
||||||
@@ -326,10 +326,10 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
placeholder="ค้นหากลิ่น, หมวดหมู่..."
|
placeholder="ค้นหากลิ่น, หมวดหมู่..."
|
||||||
className="bg-transparent border-none outline-none text-lg md:text-xl p-4 pl-0 w-full text-spa-forest placeholder-spa-stone/40"
|
className="bg-transparent border-none outline-none text-lg md:text-xl p-4 pl-0 w-full text-pop-black placeholder-pop-black/40"
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="bg-spa-forest text-spa-light px-4 md:px-6 font-bold text-xs uppercase tracking-widest shrink-0 hover:bg-spa-stone transition-colors">
|
<button type="submit" className="bg-pop-black text-pop-bg px-4 md:px-6 font-bold text-xs uppercase tracking-widest shrink-0 hover:bg-pop-black/80 transition-colors">
|
||||||
Go
|
Go
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,22 +342,22 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
className={`fixed inset-0 z-[110] md:hidden transition-all duration-300 ${langSheetOpen ? 'visible' : 'invisible'}`}
|
className={`fixed inset-0 z-[110] md:hidden transition-all duration-300 ${langSheetOpen ? 'visible' : 'invisible'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 bg-spa-stone/40 backdrop-blur-sm transition-opacity duration-300 ${langSheetOpen ? 'opacity-100' : 'opacity-0'}`}
|
className={`absolute inset-0 bg-pop-black/40 backdrop-blur-sm transition-opacity duration-300 ${langSheetOpen ? 'opacity-100' : 'opacity-0'}`}
|
||||||
onClick={() => setLangSheetOpen(false)}
|
onClick={() => setLangSheetOpen(false)}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute bottom-0 left-0 right-0 bg-spa-light rounded-t-3xl transition-transform duration-300 shadow-2xl pb-safe ${langSheetOpen ? 'translate-y-0' : 'translate-y-full'}`}
|
className={`absolute bottom-0 left-0 right-0 bg-pop-bg 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="flex justify-center pt-3 pb-1">
|
||||||
<div className="w-12 h-1.5 bg-spa-stone/20 rounded-full" />
|
<div className="w-12 h-1.5 bg-pop-black/20 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-6 pb-6 pt-2">
|
<div className="px-6 pb-6 pt-2">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<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">
|
<h3 className="text-sm font-bold uppercase tracking-widest text-pop-black flex items-center gap-2">
|
||||||
<Globe className="w-4 h-4" />
|
<Globe className="w-4 h-4" />
|
||||||
Select Language
|
Select Language
|
||||||
</h3>
|
</h3>
|
||||||
<button onClick={() => setLangSheetOpen(false)} className="p-2 rounded-full hover:bg-spa-sage text-spa-stone">
|
<button onClick={() => setLangSheetOpen(false)} className="p-2 rounded-full hover:bg-pop-black/5 text-pop-black/80">
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +368,7 @@ export default function Navbar({ topOffsetClassName = 'top-0' }: NavbarProps) {
|
|||||||
<button
|
<button
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
onClick={() => changeLanguage(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'}`}
|
className={`w-full flex items-center justify-between px-4 py-4 rounded-xl transition-colors font-medium border border-transparent ${isActive ? 'bg-pop-black/10 border-pop-black/20 text-pop-black font-bold' : 'bg-pop-bg/50 text-pop-black/80 hover:bg-pop-black/5'}`}
|
||||||
>
|
>
|
||||||
<span className="text-base">{lang.label}</span>
|
<span className="text-base">{lang.label}</span>
|
||||||
{isActive && <Check className="w-5 h-5" />}
|
{isActive && <Check className="w-5 h-5" />}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
onClick={handleCardClick}
|
onClick={handleCardClick}
|
||||||
className="ui-shell-card ui-shell-card-hover group flex cursor-pointer flex-col overflow-hidden p-2"
|
className="ui-shell-card ui-shell-card-hover group flex cursor-pointer flex-col overflow-hidden p-2"
|
||||||
>
|
>
|
||||||
<div className="relative h-48 overflow-hidden rounded-xl bg-spa-sage/20 md:h-64">
|
<div className="relative h-48 overflow-hidden rounded-xl bg-pop-black/5 md:h-64">
|
||||||
<img
|
<img
|
||||||
src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)}
|
src={product.images?.[0]?.url || getCategoryImage(displayCategoryName)}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
@@ -57,26 +57,26 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
(e.target as HTMLImageElement).style.display = 'none';
|
(e.target as HTMLImageElement).style.display = 'none';
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="ui-chip absolute left-3 top-3 border-spa-light bg-spa-light px-2.5 py-1 text-[10px] text-spa-forest shadow-sm backdrop-blur-sm">
|
<div className="ui-chip absolute left-3 top-3 border-pop-bg bg-pop-bg/90 px-2.5 py-1 text-[10px] text-pop-black shadow-sm backdrop-blur-sm">
|
||||||
{displayCategoryName}
|
{displayCategoryName}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleWishlistClick}
|
onClick={handleWishlistClick}
|
||||||
className="absolute right-3 top-3 z-10 rounded-full bg-spa-light p-2 shadow-sm backdrop-blur-sm transition-transform hover:scale-110 cursor-pointer"
|
className="absolute right-3 top-3 z-10 rounded-full bg-pop-bg/90 p-2 shadow-sm backdrop-blur-sm transition-transform hover:scale-110 cursor-pointer"
|
||||||
>
|
>
|
||||||
<Heart
|
<Heart
|
||||||
className={`w-4 h-4 transition-colors ${isWished ? 'fill-rose-500 text-rose-500' : 'text-spa-stone/60 hover:text-rose-500'}`}
|
className={`w-4 h-4 transition-colors ${isWished ? 'fill-pop-pink text-pop-pink' : 'text-pop-black/40 hover:text-pop-pink'}`}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col p-4">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<div className="mb-2">
|
<div className="mb-2">
|
||||||
<h3 className="line-clamp-1 text-base font-bold leading-tight text-pop-black transition-colors group-hover:text-spa-forest md:text-lg">
|
<h3 className="line-clamp-1 text-base font-bold leading-tight text-pop-black transition-colors group-hover:text-pop-pink md:text-lg">
|
||||||
{displayName}
|
{displayName}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-spa-stone/60 md:text-xs">
|
<p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-pop-black/50 md:text-xs">
|
||||||
{displayCategoryName}
|
{displayCategoryName}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -96,8 +96,8 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
}}
|
}}
|
||||||
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
|
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
|
||||||
isSelected
|
isSelected
|
||||||
? 'border-spa-forest bg-spa-forest text-spa-light shadow-sm'
|
? 'border-pop-black bg-pop-black text-pop-bg shadow-sm'
|
||||||
: 'border-spa-stone/10 bg-spa-light text-spa-stone hover:border-spa-forest/30'
|
: 'border-pop-black/10 bg-transparent text-pop-black/70 hover:border-pop-black/30 hover:bg-pop-black/5'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{variant.size}
|
{variant.size}
|
||||||
@@ -109,11 +109,11 @@ export default function ProductCard({ product, enableQuickShop = false, category
|
|||||||
|
|
||||||
<div className="mt-auto flex flex-col gap-2 pt-3">
|
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-black text-rose-500 md:text-lg">
|
<span className="text-sm font-black text-pop-pink md:text-lg">
|
||||||
{formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
|
{formatPrice(selectedVariant?.price ?? product.variants[0]?.price)}
|
||||||
</span>
|
</span>
|
||||||
{!enableQuickShop && (
|
{!enableQuickShop && (
|
||||||
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-spa-forest transition-transform group-hover:translate-x-1 md:text-xs">
|
<span className="flex items-center gap-1 text-[10px] font-bold uppercase tracking-widest text-pop-black transition-transform group-hover:translate-x-1 group-hover:text-pop-pink md:text-xs">
|
||||||
{t('product.viewDetails')}
|
{t('product.viewDetails')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Main Image */}
|
{/* Main Image */}
|
||||||
<div className="relative rounded-2xl overflow-hidden h-[480px] group bg-spa-light shadow-sm border border-spa-stone/10 flex items-center justify-center">
|
<div className="relative rounded-2xl overflow-hidden h-[480px] group bg-pop-bg shadow-sm border border-pop-black/10 flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={currentImageUrl}
|
src={currentImageUrl}
|
||||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||||
@@ -38,7 +38,7 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/10 to-transparent pointer-events-none" />
|
||||||
|
|
||||||
{badgeText && (
|
{badgeText && (
|
||||||
<div className="absolute top-6 left-6 bg-spa-light backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-spa-forest shadow-sm pointer-events-none">
|
<div className="absolute top-6 left-6 bg-pop-bg backdrop-blur-sm px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-pop-pink shadow-sm pointer-events-none">
|
||||||
{badgeText}
|
{badgeText}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -48,7 +48,7 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handlePrev}
|
onClick={handlePrev}
|
||||||
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-spa-light backdrop-blur-md rounded-full flex items-center justify-center text-spa-forest opacity-0 group-hover:opacity-100 transition-opacity hover:bg-spa-light hover:scale-110 shadow-sm"
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-pop-bg backdrop-blur-md rounded-full flex items-center justify-center text-pop-black opacity-0 group-hover:opacity-100 transition-opacity hover:bg-pop-bg hover:text-pop-pink hover:scale-110 shadow-sm"
|
||||||
aria-label="Previous image"
|
aria-label="Previous image"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -57,7 +57,7 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleNext}
|
onClick={handleNext}
|
||||||
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-spa-light backdrop-blur-md rounded-full flex items-center justify-center text-spa-forest opacity-0 group-hover:opacity-100 transition-opacity hover:bg-spa-light hover:scale-110 shadow-sm"
|
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-pop-bg backdrop-blur-md rounded-full flex items-center justify-center text-pop-black opacity-0 group-hover:opacity-100 transition-opacity hover:bg-pop-bg hover:text-pop-pink hover:scale-110 shadow-sm"
|
||||||
aria-label="Next image"
|
aria-label="Next image"
|
||||||
>
|
>
|
||||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
@@ -76,7 +76,7 @@ export default function ProductImageGallery({ images, fallback, alt = 'Product I
|
|||||||
key={image.id || idx}
|
key={image.id || idx}
|
||||||
onClick={() => setCurrentIndex(idx)}
|
onClick={() => setCurrentIndex(idx)}
|
||||||
className={`relative flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden border-2 transition-all ${
|
className={`relative flex-shrink-0 w-16 h-16 rounded-xl overflow-hidden border-2 transition-all ${
|
||||||
currentIndex === idx ? 'border-spa-forest shadow-md' : 'border-transparent hover:border-spa-stone/30'
|
currentIndex === idx ? 'border-pop-black shadow-md' : 'border-transparent hover:border-pop-black/30'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -79,29 +79,29 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
const visibleReviews = filteredReviews.slice(0, visibleCount)
|
const visibleReviews = filteredReviews.slice(0, visibleCount)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="pt-16 border-t border-spa-stone/10 space-y-12">
|
<div className="pt-16 border-t border-pop-black/10 space-y-12">
|
||||||
<div className="flex flex-col md:flex-row gap-12 items-start">
|
<div className="flex flex-col md:flex-row gap-12 items-start">
|
||||||
{/* Summary */}
|
{/* Summary */}
|
||||||
<div className="w-full md:w-1/3 bg-spa-light p-8 rounded-3xl text-center space-y-4">
|
<div className="w-full md:w-1/3 bg-pop-bg p-8 rounded-3xl text-center space-y-4">
|
||||||
<h2 className="text-3xl font-bold text-pop-black">{t('product.reviews')}</h2>
|
<h2 className="text-3xl font-bold text-pop-black">{t('product.reviews')}</h2>
|
||||||
<div className="text-6xl font-black text-spa-forest">{avgRating}</div>
|
<div className="text-6xl font-black text-pop-pink">{avgRating}</div>
|
||||||
<div className="flex justify-center gap-1">
|
<div className="flex justify-center gap-1">
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
<Star key={star} className={`w-5 h-5 ${star <= Number(avgRating) ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30 fill-spa-stone/10'}`} />
|
<Star key={star} className={`w-5 h-5 ${star <= Number(avgRating) ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30 fill-pop-black/10'}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-spa-stone text-sm">{reviews.length} reviews</p>
|
<p className="text-pop-black/60 text-sm">{reviews.length} reviews</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List & Form */}
|
{/* List & Form */}
|
||||||
<div className="w-full md:w-2/3 space-y-8">
|
<div className="w-full md:w-2/3 space-y-8">
|
||||||
{customer ? (
|
{customer ? (
|
||||||
<form onSubmit={handleSubmit} className="bg-spa-light p-6 rounded-3xl border border-spa-stone/10 shadow-sm space-y-4">
|
<form onSubmit={handleSubmit} className="bg-pop-bg p-6 rounded-3xl border border-pop-black/10 shadow-sm space-y-4">
|
||||||
<h3 className="font-bold text-pop-black">Write a Review</h3>
|
<h3 className="font-bold text-pop-black">Write a Review</h3>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
<button type="button" key={star} onClick={() => setRating(star)} className="focus:outline-none">
|
<button type="button" key={star} onClick={() => setRating(star)} className="focus:outline-none">
|
||||||
<Star className={`w-8 h-8 ${star <= rating ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30 fill-spa-stone/10 hover:text-orange-300'}`} />
|
<Star className={`w-8 h-8 ${star <= rating ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30 fill-pop-black/10 hover:text-orange-300'}`} />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -109,21 +109,21 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
value={comment}
|
value={comment}
|
||||||
onChange={e => setComment(e.target.value)}
|
onChange={e => setComment(e.target.value)}
|
||||||
placeholder="Share your experience with this scent..."
|
placeholder="Share your experience with this scent..."
|
||||||
className="w-full bg-spa-light/50 border border-spa-stone/20 rounded-xl p-4 text-sm focus:outline-none focus:border-spa-forest/50 focus:ring-2 focus:ring-spa-forest/20 transition-all"
|
className="w-full bg-pop-bg/50 border border-pop-black/20 rounded-xl p-4 text-sm focus:outline-none focus:border-pop-pink/50 focus:ring-2 focus:ring-pop-pink/20 transition-all text-pop-black placeholder:text-pop-black/50"
|
||||||
rows={3}
|
rows={3}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
className="px-6 py-2.5 bg-spa-forest text-spa-light text-sm font-bold tracking-widest uppercase rounded-full hover:bg-spa-stone transition-all disabled:opacity-50"
|
className="px-6 py-2.5 bg-pop-pink text-pop-bg text-sm font-bold tracking-widest uppercase rounded-full hover:bg-pop-black transition-all disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{submitting ? 'Submitting...' : 'Submit Review'}
|
{submitting ? 'Submitting...' : 'Submit Review'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-spa-light p-6 rounded-3xl text-center border border-dashed border-spa-stone/30">
|
<div className="bg-pop-bg p-6 rounded-3xl text-center border border-dashed border-pop-black/30">
|
||||||
<p className="text-spa-stone text-sm mb-2">Login to write a review</p>
|
<p className="text-pop-black/60 text-sm mb-2">Login to write a review</p>
|
||||||
<button onClick={() => window.location.href='/track'} className="text-spa-forest font-bold text-sm tracking-widest uppercase hover:underline">Go to Login</button>
|
<button onClick={() => window.location.href='/track'} className="text-pop-pink font-bold text-sm tracking-widest uppercase hover:underline">Go to Login</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -135,8 +135,8 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
onClick={() => { setFilterRating(null); setVisibleCount(3); }}
|
onClick={() => { setFilterRating(null); setVisibleCount(3); }}
|
||||||
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
className={`flex-shrink-0 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||||
filterRating === null
|
filterRating === null
|
||||||
? 'bg-spa-stone text-spa-light border-spa-stone'
|
? 'bg-pop-black text-pop-bg border-pop-black'
|
||||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
: 'bg-pop-bg text-pop-black border-pop-black/20 hover:border-pop-black/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
All ({reviews.length})
|
All ({reviews.length})
|
||||||
@@ -150,11 +150,11 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
onClick={() => { setFilterRating(star); setVisibleCount(3); }}
|
onClick={() => { setFilterRating(star); setVisibleCount(3); }}
|
||||||
className={`flex-shrink-0 flex items-center gap-1 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
className={`flex-shrink-0 flex items-center gap-1 px-4 py-1.5 rounded-full text-xs font-bold transition-all border ${
|
||||||
filterRating === star
|
filterRating === star
|
||||||
? 'bg-spa-stone text-spa-light border-spa-stone'
|
? 'bg-pop-black text-pop-bg border-pop-black'
|
||||||
: 'bg-spa-light text-spa-stone border-spa-stone/20 hover:border-spa-stone/50'
|
: 'bg-pop-bg text-pop-black border-pop-black/20 hover:border-pop-black/50'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{star} <Star className={`w-3 h-3 ${filterRating === star ? 'fill-white text-spa-light' : 'fill-orange-400 text-orange-400'}`} /> ({count})
|
{star} <Star className={`w-3 h-3 ${filterRating === star ? 'fill-white text-pop-bg' : 'fill-orange-400 text-orange-400'}`} /> ({count})
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -162,19 +162,19 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-spa-stone animate-pulse text-sm">Loading reviews...</p>
|
<p className="text-pop-black/60 animate-pulse text-sm">Loading reviews...</p>
|
||||||
) : reviews.length === 0 ? (
|
) : reviews.length === 0 ? (
|
||||||
<p className="text-spa-stone/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
<p className="text-pop-black/60 text-sm italic">No reviews yet. Be the first to share your thoughts!</p>
|
||||||
) : visibleReviews.length === 0 ? (
|
) : visibleReviews.length === 0 ? (
|
||||||
<p className="text-spa-stone/60 text-sm italic">No reviews found for this rating.</p>
|
<p className="text-pop-black/60 text-sm italic">No reviews found for this rating.</p>
|
||||||
) : (
|
) : (
|
||||||
visibleReviews.map(review => (
|
visibleReviews.map(review => (
|
||||||
<div key={review.id} className="border-b border-spa-stone/10 pb-6 last:border-0">
|
<div key={review.id} className="border-b border-pop-black/10 pb-6 last:border-0">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
{review.customer?.pictureUrl ? (
|
{review.customer?.pictureUrl ? (
|
||||||
<img src={review.customer.pictureUrl} className="w-10 h-10 rounded-full object-cover" />
|
<img src={review.customer.pictureUrl} className="w-10 h-10 rounded-full object-cover" />
|
||||||
) : (
|
) : (
|
||||||
<div className="w-10 h-10 rounded-full bg-spa-stone/20 flex items-center justify-center text-spa-light font-bold">
|
<div className="w-10 h-10 rounded-full bg-pop-black/20 flex items-center justify-center text-pop-bg font-bold">
|
||||||
{(review.customerName || 'A')[0].toUpperCase()}
|
{(review.customerName || 'A')[0].toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -182,13 +182,13 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
<p className="font-bold text-pop-black text-sm">{review.customerName}</p>
|
<p className="font-bold text-pop-black text-sm">{review.customerName}</p>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
{[1, 2, 3, 4, 5].map(star => (
|
{[1, 2, 3, 4, 5].map(star => (
|
||||||
<Star key={star} className={`w-3 h-3 ${star <= review.rating ? 'fill-orange-400 text-orange-400' : 'text-spa-stone/30'}`} />
|
<Star key={star} className={`w-3 h-3 ${star <= review.rating ? 'fill-orange-400 text-orange-400' : 'text-pop-black/30'}`} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="ml-auto text-xs text-spa-stone/50">{new Date(review.createdAt).toLocaleDateString()}</span>
|
<span className="ml-auto text-xs text-pop-black/50">{new Date(review.createdAt).toLocaleDateString()}</span>
|
||||||
</div>
|
</div>
|
||||||
{review.comment && <p className="text-spa-stone text-sm ml-13 leading-relaxed">{review.comment}</p>}
|
{review.comment && <p className="text-pop-black/60 text-sm ml-13 leading-relaxed">{review.comment}</p>}
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -197,7 +197,7 @@ export default function ProductReviews({ productId }: { productId: number }) {
|
|||||||
<div className="text-center pt-4">
|
<div className="text-center pt-4">
|
||||||
<button
|
<button
|
||||||
onClick={() => setVisibleCount(prev => prev + 5)}
|
onClick={() => setVisibleCount(prev => prev + 5)}
|
||||||
className="px-6 py-2.5 border border-spa-stone/20 text-spa-stone text-xs font-bold tracking-widest uppercase rounded-full hover:border-spa-stone hover:text-pop-black transition-all bg-spa-light"
|
className="px-6 py-2.5 border border-pop-black/20 text-pop-black/60 text-xs font-bold tracking-widest uppercase rounded-full hover:border-pop-black hover:text-pop-black transition-all bg-pop-bg"
|
||||||
>
|
>
|
||||||
Show More Reviews
|
Show More Reviews
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,12 +16,6 @@ export default function ThemeInjector() {
|
|||||||
// Map tokens from the backend (which use brandPrimary etc.)
|
// Map tokens from the backend (which use brandPrimary etc.)
|
||||||
// to the CSS variables expected by SPA's tailwind.config.js
|
// to the CSS variables expected by SPA's tailwind.config.js
|
||||||
const mapping: Record<string, string> = {
|
const mapping: Record<string, string> = {
|
||||||
'--color-spa-light': tokens.surfacePage,
|
|
||||||
'--color-spa-sage': tokens.brandSoft,
|
|
||||||
'--color-spa-stone': tokens.mutedText,
|
|
||||||
'--color-spa-forest': tokens.brandPrimary,
|
|
||||||
'--color-spa-gold': tokens.brandAccent,
|
|
||||||
|
|
||||||
'--color-pop-pink': tokens.brandPrimary,
|
'--color-pop-pink': tokens.brandPrimary,
|
||||||
'--color-pop-yellow': tokens.brandAccent,
|
'--color-pop-yellow': tokens.brandAccent,
|
||||||
'--color-pop-orange': tokens.brandWarm,
|
'--color-pop-orange': tokens.brandWarm,
|
||||||
|
|||||||
@@ -19,17 +19,17 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band w-full bg-spa-sand/20 px-4 md:px-8">
|
<section className="ui-section-band w-full bg-pop-bg px-4 md:px-8">
|
||||||
<div className="ui-section-inner">
|
<div className="ui-section-inner">
|
||||||
{title && (
|
{title && (
|
||||||
<h2 className="mb-10 text-center font-serif text-3xl text-spa-charcoal md:text-4xl">
|
<h2 className="mb-10 text-center font-serif text-3xl text-pop-black md:text-4xl">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
)}
|
)}
|
||||||
{payload.categories.map((category, catIndex) => (
|
{payload.categories.map((category, catIndex) => (
|
||||||
<div key={catIndex} className="mb-12 last:mb-0">
|
<div key={catIndex} className="mb-12 last:mb-0">
|
||||||
<h3 className="mb-6 flex items-center gap-3 font-serif text-2xl text-spa-charcoal">
|
<h3 className="mb-6 flex items-center gap-3 font-serif text-2xl text-pop-black">
|
||||||
{category.icon && <span className="text-spa-sage">{category.icon}</span>}
|
{category.icon && <span className="text-pop-pink">{category.icon}</span>}
|
||||||
{getLocalizedField(category.name, lang)}
|
{getLocalizedField(category.name, lang)}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
@@ -43,13 +43,15 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-spa-sage/50"
|
className={`flex w-full items-center justify-between p-5 text-left focus:outline-none focus:ring-2 focus:ring-pop-pink/50 hover:bg-pop-black/5 transition-colors ${
|
||||||
|
isOpen ? 'bg-pop-black/5' : ''
|
||||||
|
}`}
|
||||||
onClick={() => toggleAccordion(indexKey)}
|
onClick={() => toggleAccordion(indexKey)}
|
||||||
>
|
>
|
||||||
<span className="pr-4 text-lg font-medium text-spa-charcoal">
|
<span className="pr-4 text-lg font-medium text-pop-black">
|
||||||
{getLocalizedField(item.q, lang)}
|
{getLocalizedField(item.q, lang)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-shrink-0 text-spa-sage">
|
<span className="flex-shrink-0 text-pop-pink">
|
||||||
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
{isOpen ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -58,7 +60,7 @@ export default function AccordionSection({ payload }: AccordionSectionProps) {
|
|||||||
isOpen ? 'max-h-96 pb-5 opacity-100' : 'max-h-0 opacity-0'
|
isOpen ? 'max-h-96 pb-5 opacity-100' : 'max-h-0 opacity-0'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<p className="text-spa-charcoal/80 leading-relaxed whitespace-pre-wrap">
|
<p className="text-pop-black/80 leading-relaxed whitespace-pre-wrap">
|
||||||
{getLocalizedField(item.a, lang)}
|
{getLocalizedField(item.a, lang)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -118,31 +118,31 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="w-full py-20 px-4 md:px-8 bg-spa-sand/30">
|
<section className="w-full py-20 px-4 md:px-8 bg-night-900">
|
||||||
<div className="max-w-3xl mx-auto">
|
<div className="max-w-3xl mx-auto">
|
||||||
<div className="text-center mb-12">
|
<div className="text-center mb-12">
|
||||||
<h2 className="text-3xl md:text-4xl font-serif text-spa-charcoal mb-4">
|
<h2 className="text-3xl md:text-4xl font-serif text-pop-black mb-4">
|
||||||
{title || copy.title}
|
{title || copy.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-lg text-spa-charcoal/70 max-w-xl mx-auto">
|
<p className="text-lg text-pop-black/70 max-w-xl mx-auto">
|
||||||
{subtitle || copy.subtitle}
|
{subtitle || copy.subtitle}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-spa-light rounded-3xl shadow-xl shadow-spa-sand/50 p-8 md:p-12 border border-spa-sage/10">
|
<div className="bg-pop-bg rounded-3xl shadow-xl shadow-pop-black/5 p-8 md:p-12 border border-pop-black/10">
|
||||||
{isSuccess ? (
|
{isSuccess ? (
|
||||||
<div className="text-center py-12">
|
<div className="text-center py-12">
|
||||||
<div className="w-16 h-16 bg-green-100 text-green-600 rounded-full flex items-center justify-center mx-auto mb-6">
|
<div className="w-16 h-16 bg-pop-black/5 text-pop-black rounded-full flex items-center justify-center mx-auto mb-6">
|
||||||
<Send size={32} />
|
<Send size={32} />
|
||||||
</div>
|
</div>
|
||||||
<h3 className="text-2xl font-serif text-spa-charcoal mb-2">{copy.successTitle}</h3>
|
<h3 className="text-2xl font-serif text-pop-black mb-2">{copy.successTitle}</h3>
|
||||||
<p className="text-spa-charcoal/70">{copy.successBody}</p>
|
<p className="text-pop-black/70">{copy.successBody}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-6">
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="name" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="name" className="block text-sm font-medium text-pop-black/80">
|
||||||
{copy.name}
|
{copy.name}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -152,12 +152,12 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
required
|
required
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all"
|
className="w-full px-4 py-3 rounded-xl border border-pop-black/20 focus:outline-none focus:ring-2 focus:ring-pop-pink/50 focus:border-pop-pink/50 bg-pop-black/5 transition-all"
|
||||||
placeholder={copy.namePlaceholder}
|
placeholder={copy.namePlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="email" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="email" className="block text-sm font-medium text-pop-black/80">
|
||||||
{copy.email}
|
{copy.email}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -167,14 +167,14 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
required
|
required
|
||||||
value={formData.email}
|
value={formData.email}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all"
|
className="w-full px-4 py-3 rounded-xl border border-pop-black/20 focus:outline-none focus:ring-2 focus:ring-pop-pink/50 focus:border-pop-pink/50 bg-pop-black/5 transition-all"
|
||||||
placeholder="example@email.com"
|
placeholder="example@email.com"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label htmlFor="message" className="block text-sm font-medium text-spa-charcoal/80">
|
<label htmlFor="message" className="block text-sm font-medium text-pop-black/80">
|
||||||
{copy.message}
|
{copy.message}
|
||||||
</label>
|
</label>
|
||||||
<textarea
|
<textarea
|
||||||
@@ -184,7 +184,7 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
value={formData.message}
|
value={formData.message}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
rows={5}
|
rows={5}
|
||||||
className="w-full px-4 py-3 rounded-xl border border-spa-sage/30 focus:outline-none focus:ring-2 focus:ring-spa-sage/50 bg-spa-sand/5 transition-all resize-none"
|
className="w-full px-4 py-3 rounded-xl border border-pop-black/20 focus:outline-none focus:ring-2 focus:ring-pop-pink/50 focus:border-pop-pink/50 bg-pop-black/5 transition-all resize-none"
|
||||||
placeholder={copy.messagePlaceholder}
|
placeholder={copy.messagePlaceholder}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,10 +192,10 @@ export default function ContactFormSection({ payload }: ContactFormSectionProps)
|
|||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
className="w-full py-4 px-6 bg-spa-sage hover:bg-spa-sage-dark text-spa-light rounded-xl font-medium flex items-center justify-center gap-2 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
className="w-full py-4 px-6 bg-pop-black hover:bg-pop-pink text-pop-bg rounded-xl font-medium flex items-center justify-center gap-2 transition-colors disabled:opacity-70 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<span className="w-6 h-6 border-2 border-spa-light border-t-white rounded-full animate-spin" />
|
<span className="w-6 h-6 border-2 border-pop-bg/30 border-t-pop-bg rounded-full animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Send size={18} />
|
<Send size={18} />
|
||||||
|
|||||||
@@ -30,11 +30,11 @@ export default function ContactInfoSection({ payload }: ContactInfoSectionProps)
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="ui-section-band w-full bg-spa-light px-4 md:px-8">
|
<section className="ui-section-band w-full bg-pop-bg px-4 md:px-8">
|
||||||
<div className="ui-section-inner">
|
<div className="ui-section-inner">
|
||||||
{title && (
|
{title && (
|
||||||
<div className="mb-10 text-center">
|
<div className="mb-10 text-center">
|
||||||
<h2 className="font-serif text-3xl text-spa-charcoal md:text-4xl">
|
<h2 className="font-serif text-3xl text-pop-black md:text-4xl">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,17 +44,17 @@ export default function ContactInfoSection({ payload }: ContactInfoSectionProps)
|
|||||||
if (!value) return null;
|
if (!value) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={label} className="ui-shell-card flex flex-col items-center p-6 text-center">
|
<div key={label} className="ui-shell-card flex flex-col items-center p-6 text-center hover:-translate-y-1 hover:shadow-lg transition-all duration-300">
|
||||||
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-spa-sage/10 text-spa-sage">
|
<div className="mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-pop-black/5 text-pop-black">
|
||||||
<Icon size={24} />
|
<Icon size={24} />
|
||||||
</div>
|
</div>
|
||||||
<h4 className="mb-2 font-serif text-lg text-spa-charcoal">{label}</h4>
|
<h4 className="mb-2 font-serif text-lg text-pop-black">{label}</h4>
|
||||||
{label === labelSet.email ? (
|
{label === labelSet.email ? (
|
||||||
<a href={`mailto:${value}`} className="text-spa-charcoal/70 hover:text-spa-sage transition-colors">
|
<a href={`mailto:${value}`} className="text-pop-black/70 hover:text-pop-pink transition-colors">
|
||||||
{value}
|
{value}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-spa-charcoal/70 leading-relaxed whitespace-pre-wrap">
|
<p className="text-pop-black/70 leading-relaxed whitespace-pre-wrap">
|
||||||
{value}
|
{value}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -78,11 +78,11 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
if (focusedField !== field || results.length === 0) return null;
|
if (focusedField !== field || results.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="absolute z-50 w-full bg-spa-light border border-spa-stone/20 rounded-lg mt-1 max-h-60 overflow-y-auto shadow-xl">
|
<ul className="absolute z-50 w-full bg-pop-bg border border-pop-black/20 rounded-lg mt-1 max-h-60 overflow-y-auto shadow-xl">
|
||||||
{results.map((item, idx) => (
|
{results.map((item, idx) => (
|
||||||
<li
|
<li
|
||||||
key={`${item.district}-${item.amphoe}-${item.province}-${item.zipcode}-${idx}`}
|
key={`${item.district}-${item.amphoe}-${item.province}-${item.zipcode}-${idx}`}
|
||||||
className="p-3 hover:bg-spa-sage/10 cursor-pointer text-xs text-pop-black border-b border-spa-stone/10 last:border-0"
|
className="p-3 hover:bg-pop-black/5 cursor-pointer text-xs text-pop-black border-b border-pop-black/10 last:border-0"
|
||||||
onMouseDown={(e) => {
|
onMouseDown={(e) => {
|
||||||
// use onMouseDown to prevent onBlur from firing before onClick
|
// use onMouseDown to prevent onBlur from firing before onClick
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -99,7 +99,7 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
return (
|
return (
|
||||||
<div ref={containerRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
|
<div ref={containerRef} className="grid grid-cols-2 md:grid-cols-4 gap-4 w-full">
|
||||||
<div className="space-y-1.5 relative">
|
<div className="space-y-1.5 relative">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">แขวง / ตำบล</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-pop-black/80">แขวง / ตำบล</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -107,13 +107,13 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
onChange={e => handleSearch(e.target.value, 'subdistrict')}
|
onChange={e => handleSearch(e.target.value, 'subdistrict')}
|
||||||
onFocus={() => { setFocusedField('subdistrict'); handleSearch(value.subdistrict, 'subdistrict'); }}
|
onFocus={() => { setFocusedField('subdistrict'); handleSearch(value.subdistrict, 'subdistrict'); }}
|
||||||
onBlur={() => setTimeout(() => { if (focusedField === 'subdistrict') setFocusedField(null); }, 200)}
|
onBlur={() => setTimeout(() => { if (focusedField === 'subdistrict') setFocusedField(null); }, 200)}
|
||||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
|
className="w-full bg-pop-bg border border-pop-black/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-pop-black outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
{renderDropdown('subdistrict')}
|
{renderDropdown('subdistrict')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 relative">
|
<div className="space-y-1.5 relative">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">เขต / อำเภอ</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-pop-black/80">เขต / อำเภอ</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -121,13 +121,13 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
onChange={e => handleSearch(e.target.value, 'district')}
|
onChange={e => handleSearch(e.target.value, 'district')}
|
||||||
onFocus={() => { setFocusedField('district'); handleSearch(value.district, 'district'); }}
|
onFocus={() => { setFocusedField('district'); handleSearch(value.district, 'district'); }}
|
||||||
onBlur={() => setTimeout(() => { if (focusedField === 'district') setFocusedField(null); }, 200)}
|
onBlur={() => setTimeout(() => { if (focusedField === 'district') setFocusedField(null); }, 200)}
|
||||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
|
className="w-full bg-pop-bg border border-pop-black/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-pop-black outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
{renderDropdown('district')}
|
{renderDropdown('district')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 relative">
|
<div className="space-y-1.5 relative">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">จังหวัด</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-pop-black/80">จังหวัด</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -135,13 +135,13 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
onChange={e => handleSearch(e.target.value, 'province')}
|
onChange={e => handleSearch(e.target.value, 'province')}
|
||||||
onFocus={() => { setFocusedField('province'); handleSearch(value.province, 'province'); }}
|
onFocus={() => { setFocusedField('province'); handleSearch(value.province, 'province'); }}
|
||||||
onBlur={() => setTimeout(() => { if (focusedField === 'province') setFocusedField(null); }, 200)}
|
onBlur={() => setTimeout(() => { if (focusedField === 'province') setFocusedField(null); }, 200)}
|
||||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
|
className="w-full bg-pop-bg border border-pop-black/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-pop-black outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
{renderDropdown('province')}
|
{renderDropdown('province')}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1.5 relative">
|
<div className="space-y-1.5 relative">
|
||||||
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">รหัสไปรษณีย์</label>
|
<label className="text-[10px] uppercase font-bold tracking-widest text-pop-black/80">รหัสไปรษณีย์</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
@@ -149,7 +149,7 @@ export function ThaiAddressInput({ value, onChange }: ThaiAddressInputProps) {
|
|||||||
onChange={e => handleSearch(e.target.value, 'zipcode')}
|
onChange={e => handleSearch(e.target.value, 'zipcode')}
|
||||||
onFocus={() => { setFocusedField('zipcode'); handleSearch(value.zipcode, 'zipcode'); }}
|
onFocus={() => { setFocusedField('zipcode'); handleSearch(value.zipcode, 'zipcode'); }}
|
||||||
onBlur={() => setTimeout(() => { if (focusedField === 'zipcode') setFocusedField(null); }, 200)}
|
onBlur={() => setTimeout(() => { if (focusedField === 'zipcode') setFocusedField(null); }, 200)}
|
||||||
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-spa-forest outline-none transition-all"
|
className="w-full bg-pop-bg border border-pop-black/20 rounded-lg p-2.5 text-xs text-pop-black focus:border-pop-black outline-none transition-all"
|
||||||
/>
|
/>
|
||||||
{renderDropdown('zipcode')}
|
{renderDropdown('zipcode')}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+60
-24
@@ -99,11 +99,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-section-subtitle {
|
.ui-section-subtitle {
|
||||||
@apply mx-auto max-w-2xl text-spa-stone;
|
@apply mx-auto max-w-2xl text-pop-black/60;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-shell-card {
|
.ui-shell-card {
|
||||||
@apply rounded-3xl border border-spa-stone/10 bg-spa-light shadow-brutal transition-all duration-300;
|
@apply rounded-3xl border border-pop-black/10 bg-night-900 shadow-brutal transition-all duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-shell-card-hover {
|
.ui-shell-card-hover {
|
||||||
@@ -115,31 +115,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-button-primary {
|
.ui-button-primary {
|
||||||
@apply inline-flex items-center justify-center rounded-full bg-spa-forest px-6 py-3 text-xs font-bold uppercase tracking-widest text-spa-light transition-colors hover:bg-spa-forest/90;
|
@apply inline-flex items-center justify-center rounded-full bg-pop-pink px-6 py-3 text-xs font-bold uppercase tracking-widest text-pop-bg transition-colors hover:opacity-90;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-button-secondary {
|
.ui-button-secondary {
|
||||||
@apply inline-flex items-center justify-center rounded-full bg-spa-light px-6 py-3 text-xs font-bold uppercase tracking-widest text-spa-forest transition-colors hover:bg-spa-sage;
|
@apply inline-flex items-center justify-center rounded-full bg-pop-bg px-6 py-3 text-xs font-bold uppercase tracking-widest text-pop-black transition-colors hover:bg-pop-black/5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-input-shell {
|
.ui-input-shell {
|
||||||
@apply w-full rounded-full border border-spa-light bg-spa-light px-6 py-4 text-sm text-spa-light outline-none transition placeholder:text-spa-light focus:border-spa-gold/80;
|
@apply w-full rounded-full border border-pop-black/20 bg-transparent px-6 py-4 text-sm text-pop-black outline-none transition placeholder:text-pop-black/50 focus:border-pop-pink;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-muted-text {
|
.ui-muted-text {
|
||||||
@apply text-spa-stone/70;
|
@apply text-pop-black/70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-shell {
|
.ui-hero-shell {
|
||||||
@apply relative flex min-h-[90vh] items-center justify-center overflow-hidden bg-night-900 text-spa-light md:aspect-[21/9] md:min-h-0;
|
@apply relative flex min-h-[90vh] items-center justify-center overflow-hidden bg-night-900 text-pop-bg md:aspect-[21/9] md:min-h-0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-overlay {
|
.ui-hero-overlay {
|
||||||
@apply absolute inset-0 bg-neutral-900/40;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--color-pop-bg) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-overlay-strong {
|
.ui-hero-overlay-strong {
|
||||||
@apply absolute inset-0 bg-gradient-to-t from-neutral-900/90 via-neutral-900/10 to-neutral-900/30;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(
|
||||||
|
to top,
|
||||||
|
color-mix(in srgb, var(--color-pop-bg) 90%, transparent),
|
||||||
|
color-mix(in srgb, var(--color-pop-bg) 10%, transparent),
|
||||||
|
color-mix(in srgb, var(--color-pop-bg) 30%, transparent)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-content {
|
.ui-hero-content {
|
||||||
@@ -147,11 +156,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-title {
|
.ui-hero-title {
|
||||||
@apply font-serif text-5xl font-light leading-tight tracking-wide text-spa-light drop-shadow-md md:text-7xl lg:text-8xl;
|
@apply font-serif text-5xl font-light leading-tight tracking-wide drop-shadow-md md:text-7xl lg:text-8xl;
|
||||||
|
color: var(--color-pop-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-copy {
|
.ui-hero-copy {
|
||||||
@apply mx-auto max-w-2xl pb-8 pt-4 text-lg font-light text-spa-light drop-shadow-md md:text-xl;
|
@apply mx-auto max-w-2xl pb-8 pt-4 text-lg font-light drop-shadow-md md:text-xl;
|
||||||
|
color: var(--color-pop-black);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-banner-shell {
|
.ui-banner-shell {
|
||||||
@@ -159,39 +170,39 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-banner-overlay {
|
.ui-banner-overlay {
|
||||||
@apply absolute inset-0 bg-gradient-to-r from-spa-forest/80 via-spa-forest/40 to-transparent;
|
@apply absolute inset-0 bg-gradient-to-r from-pop-black/80 via-pop-black/40 to-transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-banner-content {
|
.ui-banner-content {
|
||||||
@apply relative z-10 max-w-xl space-y-4 p-8 text-spa-light md:p-16;
|
@apply relative z-10 max-w-xl space-y-4 p-8 text-pop-bg md:p-16;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-card-soft {
|
.ui-card-soft {
|
||||||
@apply rounded-2xl border border-spa-stone/10 bg-spa-light shadow-sm;
|
@apply rounded-2xl border border-pop-black/10 bg-night-900 shadow-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dark-shell {
|
.ui-dark-shell {
|
||||||
@apply bg-spa-forest px-6 py-20 text-spa-light;
|
@apply bg-pop-black px-6 py-20 text-pop-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dark-card {
|
.ui-dark-card {
|
||||||
@apply overflow-hidden rounded-3xl border border-spa-light bg-spa-light;
|
@apply overflow-hidden rounded-3xl border border-pop-black/10 bg-night-900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dark-card-title {
|
.ui-dark-card-title {
|
||||||
@apply text-xl font-serif font-light tracking-wide text-spa-light md:text-2xl;
|
@apply text-xl font-serif font-light tracking-wide text-pop-black md:text-2xl;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dark-card-copy {
|
.ui-dark-card-copy {
|
||||||
@apply text-sm font-light leading-relaxed text-spa-light/70;
|
@apply text-sm font-light leading-relaxed text-pop-black/70;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-dark-link {
|
.ui-dark-link {
|
||||||
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-spa-gold transition-colors hover:text-spa-light;
|
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink transition-colors hover:text-pop-black;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brutal-box {
|
.brutal-box {
|
||||||
@apply bg-spa-light border border-spa-stone/20 rounded-2xl shadow-brutal transition-all duration-300;
|
@apply bg-night-900 border border-pop-black/20 rounded-2xl shadow-brutal transition-all duration-300;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brutal-box-hover {
|
.brutal-box-hover {
|
||||||
@@ -199,11 +210,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.brutal-button {
|
.brutal-button {
|
||||||
@apply px-8 py-4 bg-pop-pink text-spa-light font-medium rounded-full shadow-brutal transition-all duration-300 hover:bg-pop-red hover:-translate-y-1 hover:shadow-brutal-hover;
|
@apply px-8 py-4 bg-pop-pink text-pop-bg font-medium rounded-full shadow-brutal transition-all duration-300 hover:opacity-90 hover:-translate-y-1 hover:shadow-brutal-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brutal-button-alt {
|
.brutal-button-alt {
|
||||||
@apply px-8 py-4 bg-spa-light text-pop-pink font-medium rounded-full border border-pop-pink shadow-brutal transition-all duration-300 hover:bg-pop-bg hover:-translate-y-1 hover:shadow-brutal-hover;
|
@apply px-8 py-4 bg-transparent text-pop-pink font-medium rounded-full border border-pop-pink shadow-brutal transition-all duration-300 hover:bg-pop-pink hover:text-pop-bg hover:-translate-y-1 hover:shadow-brutal-hover;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,11 +228,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
@apply bg-spa-sage rounded-full;
|
@apply bg-pop-black/30 rounded-full;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
@apply bg-spa-sage;
|
@apply bg-pop-black/50;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Smooth Scrolling */
|
/* Smooth Scrolling */
|
||||||
@@ -243,3 +254,28 @@ html {
|
|||||||
:lang(th) .font-serif {
|
:lang(th) .font-serif {
|
||||||
font-family: 'Prompt', sans-serif !important;
|
font-family: 'Prompt', sans-serif !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-prose p {
|
||||||
|
@apply mb-4 leading-relaxed;
|
||||||
|
}
|
||||||
|
.ui-prose p:last-child {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
.ui-prose ul {
|
||||||
|
@apply list-inside list-disc mb-4 space-y-1;
|
||||||
|
}
|
||||||
|
.ui-prose ol {
|
||||||
|
@apply list-inside list-decimal mb-4 space-y-1;
|
||||||
|
}
|
||||||
|
.ui-prose h1, .ui-prose h2, .ui-prose h3, .ui-prose h4 {
|
||||||
|
@apply font-serif font-semibold mt-8 mb-4 text-pop-black;
|
||||||
|
}
|
||||||
|
.ui-prose a {
|
||||||
|
@apply text-pop-pink underline hover:opacity-80 transition-opacity;
|
||||||
|
}
|
||||||
|
.ui-prose strong {
|
||||||
|
@apply font-bold;
|
||||||
|
}
|
||||||
|
.ui-prose blockquote {
|
||||||
|
@apply border-l-4 border-pop-pink pl-4 italic opacity-80 my-4;
|
||||||
|
}
|
||||||
@@ -65,7 +65,7 @@ export default function CustomPage() {
|
|||||||
}, [slug, i18n.language]);
|
}, [slug, i18n.language]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-spa-light text-spa-forest">
|
<div className="min-h-screen bg-pop-bg text-pop-black">
|
||||||
{announcementSections.map((section) => (
|
{announcementSections.map((section) => (
|
||||||
<DynamicPageRenderer
|
<DynamicPageRenderer
|
||||||
key={section.id}
|
key={section.id}
|
||||||
@@ -78,7 +78,7 @@ export default function CustomPage() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="min-h-[60vh] flex items-center justify-center">
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
<div className="w-12 h-12 border-4 border-spa-forest border-t-transparent rounded-full animate-spin"></div>
|
<div className="w-12 h-12 border-4 border-pop-black border-t-transparent rounded-full animate-spin"></div>
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<NotFoundPage />
|
<NotFoundPage />
|
||||||
|
|||||||
@@ -123,7 +123,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-button-line {
|
.ui-button-line {
|
||||||
@apply inline-flex items-center justify-center rounded-full bg-line-primary px-4 py-2 text-xs font-bold uppercase tracking-widest text-surface-card shadow-sm transition-colors hover:bg-line-primary-hover;
|
@apply inline-flex items-center justify-center rounded-full px-4 py-2 text-xs font-bold uppercase tracking-widest shadow-sm transition-colors;
|
||||||
|
background-color: #00B900;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-button-line:hover {
|
||||||
|
background-color: #009900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-icon-button {
|
.ui-icon-button {
|
||||||
@@ -214,14 +220,14 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--color-text-main);
|
background: var(--color-surface-page);
|
||||||
color: var(--color-surface-card);
|
color: var(--color-text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-overlay {
|
.ui-hero-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: color-mix(in srgb, var(--color-text-main) 40%, transparent);
|
background: color-mix(in srgb, var(--color-surface-page) 40%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-overlay-strong {
|
.ui-hero-overlay-strong {
|
||||||
@@ -229,14 +235,14 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(
|
background: linear-gradient(
|
||||||
to top,
|
to top,
|
||||||
color-mix(in srgb, var(--color-text-main) 90%, transparent),
|
color-mix(in srgb, var(--color-surface-page) 90%, transparent),
|
||||||
color-mix(in srgb, var(--color-text-main) 10%, transparent),
|
color-mix(in srgb, var(--color-surface-page) 10%, transparent),
|
||||||
color-mix(in srgb, var(--color-text-main) 30%, transparent)
|
color-mix(in srgb, var(--color-surface-page) 30%, transparent)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-title {
|
.ui-hero-title {
|
||||||
color: var(--color-surface-card);
|
color: var(--color-text-main);
|
||||||
font-family: var(--font-display-locale);
|
font-family: var(--font-display-locale);
|
||||||
font-size: 3rem;
|
font-size: 3rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
@@ -250,7 +256,7 @@
|
|||||||
max-width: 42rem;
|
max-width: 42rem;
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
color: rgb(255 255 255 / 0.9);
|
color: color-mix(in srgb, var(--color-text-main) 90%, transparent);
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 300;
|
font-weight: 300;
|
||||||
line-height: 1.75rem;
|
line-height: 1.75rem;
|
||||||
@@ -258,28 +264,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-button {
|
.ui-hero-button {
|
||||||
border-radius: 9999px;
|
@apply inline-flex items-center justify-center rounded-full bg-[#FDFDF9] px-6 py-3 text-xs font-bold uppercase tracking-widest text-[#1F3D2D] transition-colors hover:bg-[#EAEFEA];
|
||||||
background: var(--color-surface-card);
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
padding: 1rem 2rem;
|
|
||||||
color: var(--color-text-main);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
transition: background-color 300ms ease, box-shadow 300ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-hero-link {
|
.ui-hero-link {
|
||||||
display: flex;
|
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors;
|
||||||
align-items: center;
|
color: #D4AF37;
|
||||||
gap: 0.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
padding: 1rem 2rem;
|
}
|
||||||
color: var(--color-surface-card);
|
|
||||||
font-size: 0.875rem;
|
.ui-hero-link:hover {
|
||||||
font-weight: 500;
|
color: #FDFDF9;
|
||||||
letter-spacing: 0.1em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
transition: color 150ms ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-banner-shell {
|
.ui-banner-shell {
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from './LocalizedLink'
|
import Link from './LocalizedLink'
|
||||||
|
import { ShoppingBag } from 'lucide-react'
|
||||||
import type { Product } from '@homaroma/types'
|
import type { Product } from '@homaroma/types'
|
||||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||||
import { useLang } from '../hooks/useLang'
|
import { useLang } from '../hooks/useLang'
|
||||||
|
import { useCartStore } from '../store/cartStore'
|
||||||
import WishlistButton from './WishlistButton'
|
import WishlistButton from './WishlistButton'
|
||||||
|
|
||||||
interface ProductCardProps {
|
interface ProductCardProps {
|
||||||
@@ -14,14 +17,27 @@ interface ProductCardProps {
|
|||||||
|
|
||||||
export default function ProductCard({ product }: ProductCardProps) {
|
export default function ProductCard({ product }: ProductCardProps) {
|
||||||
const lang = useLang()
|
const lang = useLang()
|
||||||
|
const { addToCart } = useCartStore()
|
||||||
|
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
|
||||||
|
|
||||||
const categoryName = resolveCategoryName(product.category, lang)
|
const categoryName = resolveCategoryName(product.category, lang)
|
||||||
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
const productName = resolveLocalized(product.name, lang) || product.scent.name
|
||||||
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||||
const firstPrice = product.variants[0]?.price ?? product.price
|
const displayPrice = selectedVariant?.price ?? product.variants[0]?.price ?? product.price
|
||||||
|
|
||||||
|
const addToCartLabels: Record<string, string> = {
|
||||||
|
th: 'เพิ่มลงตะกร้า',
|
||||||
|
en: 'Add to Cart',
|
||||||
|
ja: 'カートに追加',
|
||||||
|
zh: '加入购物车',
|
||||||
|
vi: 'Thêm vào giỏ',
|
||||||
|
ko: '장바구니에 담기'
|
||||||
|
}
|
||||||
|
const addToCartText = addToCartLabels[lang] || addToCartLabels.en
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="ui-card ui-card-hover group flex h-full flex-col overflow-hidden">
|
<div className="ui-card ui-card-hover group flex h-full cursor-pointer flex-col overflow-hidden p-2">
|
||||||
<div className="relative aspect-[4/3] overflow-hidden bg-surface-page">
|
<div className="relative aspect-[4/3] overflow-hidden rounded-xl bg-surface-page md:h-64 md:aspect-auto">
|
||||||
<WishlistButton product={product} />
|
<WishlistButton product={product} />
|
||||||
<Link href={`/products/${product.id}`} className="block h-full w-full">
|
<Link href={`/products/${product.id}`} className="block h-full w-full">
|
||||||
<Image
|
<Image
|
||||||
@@ -32,47 +48,84 @@ export default function ProductCard({ product }: ProductCardProps) {
|
|||||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
{categoryName && (
|
{categoryName && (
|
||||||
<span className="ui-status-badge absolute left-4 top-4 border-line-primary/20 bg-surface-card/90 px-3 py-1 text-[10px] uppercase tracking-widest text-text-main shadow-sm">
|
<span className="ui-chip absolute left-3 top-3 border-line-primary/20 bg-surface-card/90 px-2.5 py-1 text-[10px] text-text-main shadow-sm backdrop-blur-sm">
|
||||||
{categoryName}
|
{categoryName}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-1 flex-col gap-4 p-5">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<div className="space-y-1">
|
<div className="mb-2">
|
||||||
<h3 className="line-clamp-2 text-lg font-semibold leading-snug text-text-main">
|
<h3 className="line-clamp-1 text-base font-bold leading-tight text-text-main transition-colors group-hover:text-brand-primary md:text-lg">
|
||||||
<Link href={`/products/${product.id}`} className="transition hover:text-brand-primary">
|
<Link href={`/products/${product.id}`}>
|
||||||
{productName}
|
{productName}
|
||||||
</Link>
|
</Link>
|
||||||
</h3>
|
</h3>
|
||||||
{product.scent.name && (
|
{categoryName && (
|
||||||
<p className="text-xs font-semibold uppercase tracking-widest text-muted-text/70">
|
<p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-muted-text md:text-xs">
|
||||||
{product.scent.name}
|
{categoryName}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{product.variants.length > 0 && (
|
{product.variants.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="mb-1 mt-2 flex flex-wrap gap-1.5">
|
||||||
{product.variants.slice(0, 3).map((variant) => (
|
{product.variants.map((variant) => {
|
||||||
<span key={variant.id} className="rounded-full bg-surface-page px-3 py-1 text-xs font-semibold text-muted-text">
|
const isSelected = selectedVariant?.id === variant.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={variant.id}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setSelectedVariant(variant)
|
||||||
|
}}
|
||||||
|
className={`rounded-full border px-3 py-1 text-[10px] font-bold uppercase tracking-wider transition-all cursor-pointer ${
|
||||||
|
isSelected
|
||||||
|
? 'border-brand-primary bg-brand-primary text-surface-card shadow-sm'
|
||||||
|
: 'border-line-primary/20 bg-surface-card text-text-main hover:border-brand-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
{variant.size}
|
{variant.size}
|
||||||
</span>
|
</button>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-auto flex items-center justify-between gap-4">
|
<div className="mt-auto flex flex-col gap-2 pt-3">
|
||||||
<span className="text-lg font-bold text-brand-primary">{formatPrice(firstPrice)}</span>
|
<div className="flex items-center justify-between">
|
||||||
<Link
|
<span className="text-sm font-black text-brand-primary md:text-lg">
|
||||||
href={`/products/${product.id}`}
|
{formatPrice(displayPrice)}
|
||||||
className="ui-button-primary px-4 py-2 text-[10px]"
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedVariant && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
addToCart({
|
||||||
|
productId: product.id,
|
||||||
|
variantId: selectedVariant.id,
|
||||||
|
quantity: 1,
|
||||||
|
price: selectedVariant.price || 0,
|
||||||
|
productName: productName,
|
||||||
|
variantSize: selectedVariant.size,
|
||||||
|
productImage: imageUrl
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="ui-button-primary w-full gap-1.5 rounded-xl py-2.5 shadow-sm"
|
||||||
>
|
>
|
||||||
View
|
<ShoppingBag className="h-3.5 w-3.5" />
|
||||||
</Link>
|
{addToCartText}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,8 @@
|
|||||||
|
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import Link from '../LocalizedLink'
|
import Link from '../LocalizedLink'
|
||||||
import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
|
import { Clock, Mail, MapPin, MessageCircle, Send } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||||
import { getCategoryImage } from '@homaroma/types'
|
import { getCategoryImage } from '@homaroma/types'
|
||||||
import ProductCard from '../ProductCard'
|
import ProductCard from '../ProductCard'
|
||||||
@@ -125,12 +126,12 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
|||||||
<div className="relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0">
|
<div className="relative z-10 mx-auto mt-16 w-full max-w-4xl space-y-6 px-8 text-center md:mt-0">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{tagline && (
|
{tagline && (
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-surface-card drop-shadow-md md:text-sm">
|
<p className="text-xs font-semibold uppercase tracking-[0.3em] text-[#EAEFEA] drop-shadow-md md:text-sm">
|
||||||
{tagline}
|
{tagline}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<h1 className="ui-hero-title">
|
<h1 className="ui-hero-title">
|
||||||
{renderAccentTitle(title || '')}
|
{renderAccentTitle(title || '', 'text-[#D4AF37] font-medium italic')}
|
||||||
</h1>
|
</h1>
|
||||||
{description && (
|
{description && (
|
||||||
<p className="ui-hero-copy">
|
<p className="ui-hero-copy">
|
||||||
@@ -146,7 +147,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
|||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href="#categories"
|
href="#categories"
|
||||||
className="ui-hero-link"
|
className="ui-hero-link group"
|
||||||
>
|
>
|
||||||
{heroLearnMoreText(resolvedLang)}
|
{heroLearnMoreText(resolvedLang)}
|
||||||
<span className="transition-transform group-hover:translate-x-1">→</span>
|
<span className="transition-transform group-hover:translate-x-1">→</span>
|
||||||
@@ -208,11 +209,11 @@ function CategoriesSection({
|
|||||||
unoptimized
|
unoptimized
|
||||||
className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
className="absolute inset-0 h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-text-main/65 to-transparent" />
|
<div className="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent z-10 opacity-80 group-hover:opacity-100 transition-opacity" />
|
||||||
<div className="absolute bottom-5 left-5 right-5 text-surface-card">
|
<div className="absolute bottom-5 left-5 right-5 z-20 text-white">
|
||||||
<span className="block text-lg font-semibold">{name}</span>
|
<span className="block text-lg font-semibold">{name}</span>
|
||||||
{category._count && (
|
{category._count && (
|
||||||
<span className="text-sm text-surface-card">
|
<span className="text-sm text-white/90">
|
||||||
{category._count.products} {productsLabel[resolvedLang] ?? productsLabel.en}
|
{category._count.products} {productsLabel[resolvedLang] ?? productsLabel.en}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -665,6 +666,207 @@ function ContactInfoSection({ payload, lang }: { payload: Record<string, unknown
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ContactFormSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||||
|
const i18nLang = useLang()
|
||||||
|
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||||
|
const [formData, setFormData] = useState({ name: '', email: '', message: '' })
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [isSuccess, setIsSuccess] = useState(false)
|
||||||
|
|
||||||
|
const title = localizedFromRecord(payload, 'title', resolvedLang)
|
||||||
|
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
|
||||||
|
|
||||||
|
const labels: Record<string, Record<string, string>> = {
|
||||||
|
th: {
|
||||||
|
title: 'ติดต่อเรา',
|
||||||
|
subtitle: 'ส่งข้อความถึงทีมงานของเรา เรายินดีให้บริการและพร้อมตอบทุกข้อสงสัย',
|
||||||
|
successTitle: 'ส่งข้อความสำเร็จ',
|
||||||
|
successBody: 'ขอบคุณที่ติดต่อเรา ทีมงานจะรีบตอบกลับโดยเร็วที่สุด',
|
||||||
|
name: 'ชื่อ-นามสกุล',
|
||||||
|
namePlaceholder: 'กรอกชื่อของคุณ',
|
||||||
|
email: 'อีเมล',
|
||||||
|
message: 'ข้อความ',
|
||||||
|
messagePlaceholder: 'พิมพ์ข้อความของคุณที่นี่...',
|
||||||
|
submit: 'ส่งข้อความ',
|
||||||
|
},
|
||||||
|
en: {
|
||||||
|
title: 'Contact Us',
|
||||||
|
subtitle: 'Send a message to our team and we will get back to you soon.',
|
||||||
|
successTitle: 'Message sent successfully',
|
||||||
|
successBody: 'Thanks for reaching out. Our team will reply as soon as possible.',
|
||||||
|
name: 'Full Name',
|
||||||
|
namePlaceholder: 'Enter your name',
|
||||||
|
email: 'Email',
|
||||||
|
message: 'Message',
|
||||||
|
messagePlaceholder: 'Type your message here...',
|
||||||
|
submit: 'Send Message',
|
||||||
|
},
|
||||||
|
ja: {
|
||||||
|
title: 'お問い合わせ',
|
||||||
|
subtitle: 'チームへメッセージをお送りください。できるだけ早くご返信します。',
|
||||||
|
successTitle: '送信に成功しました',
|
||||||
|
successBody: 'お問い合わせありがとうございます。担当者ができるだけ早く返信します。',
|
||||||
|
name: '氏名',
|
||||||
|
namePlaceholder: 'お名前を入力してください',
|
||||||
|
email: 'メール',
|
||||||
|
message: 'メッセージ',
|
||||||
|
messagePlaceholder: 'ここにメッセージを入力してください...',
|
||||||
|
submit: '送信',
|
||||||
|
},
|
||||||
|
zh: {
|
||||||
|
title: '联系我们',
|
||||||
|
subtitle: '给我们的团队留言,我们会尽快回复您。',
|
||||||
|
successTitle: '发送成功',
|
||||||
|
successBody: '感谢您的联系,我们会尽快回复。',
|
||||||
|
name: '姓名',
|
||||||
|
namePlaceholder: '请输入您的姓名',
|
||||||
|
email: '邮箱',
|
||||||
|
message: '留言',
|
||||||
|
messagePlaceholder: '在这里输入您的留言...',
|
||||||
|
submit: '发送消息',
|
||||||
|
},
|
||||||
|
vi: {
|
||||||
|
title: 'Liên hệ',
|
||||||
|
subtitle: 'Gửi tin nhắn cho đội ngũ của chúng tôi, chúng tôi sẽ phản hồi sớm nhất.',
|
||||||
|
successTitle: 'Gửi tin nhắn thành công',
|
||||||
|
successBody: 'Cảm ơn bạn đã liên hệ, đội ngũ của chúng tôi sẽ phản hồi sớm nhất có thể.',
|
||||||
|
name: 'Họ và tên',
|
||||||
|
namePlaceholder: 'Nhập tên của bạn',
|
||||||
|
email: 'Email',
|
||||||
|
message: 'Tin nhắn',
|
||||||
|
messagePlaceholder: 'Nhập tin nhắn của bạn tại đây...',
|
||||||
|
submit: 'Gửi tin nhắn',
|
||||||
|
},
|
||||||
|
ko: {
|
||||||
|
title: '문의하기',
|
||||||
|
subtitle: '팀에 메시지를 보내주시면 최대한 빨리 답변드리겠습니다.',
|
||||||
|
successTitle: '메시지 전송 완료',
|
||||||
|
successBody: '문의해 주셔서 감사합니다. 가능한 빨리 답변드리겠습니다.',
|
||||||
|
name: '이름',
|
||||||
|
namePlaceholder: '이름을 입력하세요',
|
||||||
|
email: '이메일',
|
||||||
|
message: '메시지',
|
||||||
|
messagePlaceholder: '여기에 메시지를 입력하세요...',
|
||||||
|
submit: '메시지 보내기',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const copy = labels[resolvedLang] ?? labels.en
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault()
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
// Simulate API call
|
||||||
|
setTimeout(() => {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
setIsSuccess(true)
|
||||||
|
setFormData({ name: '', email: '', message: '' })
|
||||||
|
|
||||||
|
// Reset success message after 3 seconds
|
||||||
|
setTimeout(() => setIsSuccess(false), 3000)
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||||
|
const { name, value } = e.target
|
||||||
|
setFormData(prev => ({ ...prev, [name]: value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="ui-section-band bg-surface-page px-4 py-20 md:px-8">
|
||||||
|
<div className="mx-auto max-w-3xl">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<h2 className="mb-4 text-3xl font-semibold text-text-main md:text-4xl">
|
||||||
|
{title || copy.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mx-auto max-w-xl text-lg text-muted-text">
|
||||||
|
{subtitle || copy.subtitle}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-3xl border border-line-primary/20 bg-surface-card p-8 shadow-card md:p-12">
|
||||||
|
{isSuccess ? (
|
||||||
|
<div className="py-12 text-center">
|
||||||
|
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-brand-success/20 text-brand-success">
|
||||||
|
<Send size={32} />
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-2 text-2xl font-semibold text-text-main">{copy.successTitle}</h3>
|
||||||
|
<p className="text-muted-text">{copy.successBody}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-6">
|
||||||
|
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="name" className="block text-sm font-medium text-text-main">
|
||||||
|
{copy.name}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="name"
|
||||||
|
name="name"
|
||||||
|
required
|
||||||
|
value={formData.name}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-xl border border-line-primary/30 bg-surface-page px-4 py-3 text-text-main outline-none transition-all focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
placeholder={copy.namePlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="email" className="block text-sm font-medium text-text-main">
|
||||||
|
{copy.email}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
required
|
||||||
|
value={formData.email}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="w-full rounded-xl border border-line-primary/30 bg-surface-page px-4 py-3 text-text-main outline-none transition-all focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
placeholder="example@email.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label htmlFor="message" className="block text-sm font-medium text-text-main">
|
||||||
|
{copy.message}
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="message"
|
||||||
|
name="message"
|
||||||
|
required
|
||||||
|
value={formData.message}
|
||||||
|
onChange={handleChange}
|
||||||
|
rows={5}
|
||||||
|
className="w-full resize-none rounded-xl border border-line-primary/30 bg-surface-page px-4 py-3 text-text-main outline-none transition-all focus:border-brand-primary focus:ring-2 focus:ring-brand-primary/20"
|
||||||
|
placeholder={copy.messagePlaceholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-xl bg-brand-primary px-6 py-4 font-semibold text-white transition-colors hover:bg-brand-primary-hover disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{isSubmitting ? (
|
||||||
|
<span className="h-6 w-6 animate-spin rounded-full border-2 border-white/30 border-t-white" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Send size={18} />
|
||||||
|
{copy.submit}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
|
export default function DynamicPageRenderer({ sections, categories, lang: langProp }: DynamicPageRendererProps) {
|
||||||
const i18nLang = useLang()
|
const i18nLang = useLang()
|
||||||
// Use i18n.language reactively — switches immediately when user changes language
|
// Use i18n.language reactively — switches immediately when user changes language
|
||||||
@@ -709,6 +911,8 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
|||||||
return <AccordionSection key={section.id} payload={payload} lang={lang} />
|
return <AccordionSection key={section.id} payload={payload} lang={lang} />
|
||||||
case 'CONTACT_INFO':
|
case 'CONTACT_INFO':
|
||||||
return <ContactInfoSection key={section.id} payload={payload} lang={lang} />
|
return <ContactInfoSection key={section.id} payload={payload} lang={lang} />
|
||||||
|
case 'CONTACT_FORM':
|
||||||
|
return <ContactFormSection key={section.id} payload={payload} lang={lang} />
|
||||||
default:
|
default:
|
||||||
if (process.env.NODE_ENV !== 'production') {
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
|
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
|
||||||
|
|||||||
@@ -12,10 +12,7 @@ export function splitCmsLayoutSections(sections: PageSection[]) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
announcementSections: activeSections.filter((section) => announcementTypes.has(sectionType(section))),
|
announcementSections: activeSections.filter((section) => announcementTypes.has(sectionType(section))),
|
||||||
heroSections: activeSections.filter((section) => heroTypes.has(sectionType(section))),
|
heroSections: [], // Keep for backwards compatibility with page components
|
||||||
bottomSections: activeSections.filter((section) => {
|
bottomSections: activeSections.filter((section) => !announcementTypes.has(sectionType(section))),
|
||||||
const type = sectionType(section)
|
|
||||||
return !announcementTypes.has(type) && !heroTypes.has(type)
|
|
||||||
}),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user