This commit is contained in:
Your Name committed 2026-06-21 07:04:03 -04:00
1 parent f7ac94ca0d
commit 335f3b4f59
41 files changed
+809 -413

No files matched your search

+24 -28
View File
@@ -123,7 +123,13 @@
}
.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 {
@@ -214,14 +220,14 @@
align-items: center;
justify-content: center;
overflow: hidden;
background: var(--color-text-main);
color: var(--color-surface-card);
background: var(--color-surface-page);
color: var(--color-text-main);
}
.ui-hero-overlay {
position: absolute;
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 {
@@ -229,14 +235,14 @@
inset: 0;
background: linear-gradient(
to top,
color-mix(in srgb, var(--color-text-main) 90%, transparent),
color-mix(in srgb, var(--color-text-main) 10%, transparent),
color-mix(in srgb, var(--color-text-main) 30%, transparent)
color-mix(in srgb, var(--color-surface-page) 90%, transparent),
color-mix(in srgb, var(--color-surface-page) 10%, transparent),
color-mix(in srgb, var(--color-surface-page) 30%, transparent)
);
}
.ui-hero-title {
color: var(--color-surface-card);
color: var(--color-text-main);
font-family: var(--font-display-locale);
font-size: 3rem;
font-weight: 300;
@@ -250,7 +256,7 @@
max-width: 42rem;
padding-top: 1rem;
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-weight: 300;
line-height: 1.75rem;
@@ -258,28 +264,18 @@
}
.ui-hero-button {
border-radius: 9999px;
background: var(--color-surface-card);
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;
@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];
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}
.ui-hero-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem 2rem;
color: var(--color-surface-card);
font-size: 0.875rem;
font-weight: 500;
letter-spacing: 0.1em;
text-transform: uppercase;
transition: color 150ms ease;
@apply inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors;
color: #D4AF37;
padding: 0.75rem 1.5rem;
}
.ui-hero-link:hover {
color: #FDFDF9;
}
.ui-banner-shell {
+79 -26
View File
@@ -1,11 +1,14 @@
"use client"
import { useState } from 'react'
import Image from 'next/image'
import Link from './LocalizedLink'
import { ShoppingBag } from 'lucide-react'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang'
import { useCartStore } from '../store/cartStore'
import WishlistButton from './WishlistButton'
interface ProductCardProps {
@@ -14,14 +17,27 @@ interface ProductCardProps {
export default function ProductCard({ product }: ProductCardProps) {
const lang = useLang()
const { addToCart } = useCartStore()
const [selectedVariant, setSelectedVariant] = useState(product.variants?.[0] || null)
const categoryName = resolveCategoryName(product.category, lang)
const productName = resolveLocalized(product.name, lang) || product.scent.name
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
const firstPrice = product.variants[0]?.price ?? product.price
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 (
<article className="ui-card ui-card-hover group flex h-full flex-col overflow-hidden">
<div className="relative aspect-[4/3] overflow-hidden bg-surface-page">
<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 rounded-xl bg-surface-page md:h-64 md:aspect-auto">
<WishlistButton product={product} />
<Link href={`/products/${product.id}`} className="block h-full w-full">
<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"
/>
{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}
</span>
)}
</Link>
</div>
<div className="flex flex-1 flex-col gap-4 p-5">
<div className="space-y-1">
<h3 className="line-clamp-2 text-lg font-semibold leading-snug text-text-main">
<Link href={`/products/${product.id}`} className="transition hover:text-brand-primary">
<div className="flex flex-1 flex-col p-4">
<div className="mb-2">
<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}`}>
{productName}
</Link>
</h3>
{product.scent.name && (
<p className="text-xs font-semibold uppercase tracking-widest text-muted-text/70">
{product.scent.name}
{categoryName && (
<p className="mt-1 line-clamp-1 text-[10px] font-semibold uppercase tracking-widest text-muted-text md:text-xs">
{categoryName}
</p>
)}
</div>
{product.variants.length > 0 && (
<div className="flex flex-wrap gap-2">
{product.variants.slice(0, 3).map((variant) => (
<span key={variant.id} className="rounded-full bg-surface-page px-3 py-1 text-xs font-semibold text-muted-text">
{variant.size}
</span>
))}
<div className="mb-1 mt-2 flex flex-wrap gap-1.5">
{product.variants.map((variant) => {
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}
</button>
)
})}
</div>
)}
<div className="mt-auto flex items-center justify-between gap-4">
<span className="text-lg font-bold text-brand-primary">{formatPrice(firstPrice)}</span>
<Link
href={`/products/${product.id}`}
className="ui-button-primary px-4 py-2 text-[10px]"
>
View
</Link>
<div className="mt-auto flex flex-col gap-2 pt-3">
<div className="flex items-center justify-between">
<span className="text-sm font-black text-brand-primary md:text-lg">
{formatPrice(displayPrice)}
</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"
>
<ShoppingBag className="h-3.5 w-3.5" />
{addToCartText}
</button>
)}
</div>
</div>
</article>
</div>
)
}
@@ -2,7 +2,8 @@
import Image from 'next/image'
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 { getCategoryImage } from '@homaroma/types'
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="space-y-6">
{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}
</p>
)}
<h1 className="ui-hero-title">
{renderAccentTitle(title || '')}
{renderAccentTitle(title || '', 'text-[#D4AF37] font-medium italic')}
</h1>
{description && (
<p className="ui-hero-copy">
@@ -146,7 +147,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
</Link>
<a
href="#categories"
className="ui-hero-link"
className="ui-hero-link group"
>
{heroLearnMoreText(resolvedLang)}
<span className="transition-transform group-hover:translate-x-1">→</span>
@@ -208,11 +209,11 @@ function CategoriesSection({
unoptimized
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 bottom-5 left-5 right-5 text-surface-card">
<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 z-20 text-white">
<span className="block text-lg font-semibold">{name}</span>
{category._count && (
<span className="text-sm text-surface-card">
<span className="text-sm text-white/90">
{category._count.products} {productsLabel[resolvedLang] ?? productsLabel.en}
</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) {
const i18nLang = useLang()
// 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} />
case 'CONTACT_INFO':
return <ContactInfoSection key={section.id} payload={payload} lang={lang} />
case 'CONTACT_FORM':
return <ContactFormSection key={section.id} payload={payload} lang={lang} />
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
+2 -5
View File
@@ -12,10 +12,7 @@ export function splitCmsLayoutSections(sections: PageSection[]) {
return {
announcementSections: activeSections.filter((section) => announcementTypes.has(sectionType(section))),
heroSections: activeSections.filter((section) => heroTypes.has(sectionType(section))),
bottomSections: activeSections.filter((section) => {
const type = sectionType(section)
return !announcementTypes.has(type) && !heroTypes.has(type)
}),
heroSections: [], // Keep for backwards compatibility with page components
bottomSections: activeSections.filter((section) => !announcementTypes.has(sectionType(section))),
}
}