This commit is contained in:
Your Name committed 2026-06-18 20:44:38 -04:00
1 parent 62db477dbd
commit feeda6dc60
7 files changed
+263 -83

No files matched your search

+85 -11
View File
@@ -44,6 +44,10 @@ interface Scent {
export default function PageBuilderPage() { export default function PageBuilderPage() {
const frontendBase = (import.meta.env.VITE_FRONTEND_URL || window.location.origin).replace(/\/$/, ''); const frontendBase = (import.meta.env.VITE_FRONTEND_URL || window.location.origin).replace(/\/$/, '');
const authHeaders = () => ({
'Content-Type': 'application/json',
Authorization: `Bearer ${localStorage.getItem('homaroma_admin_token')}`,
});
const [page, setPage] = useState<Page | null>(null); const [page, setPage] = useState<Page | null>(null);
const [sections, setSections] = useState<PageSection[]>([]); const [sections, setSections] = useState<PageSection[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
@@ -53,6 +57,7 @@ export default function PageBuilderPage() {
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null); const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null); const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle'); const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false); const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
@@ -293,7 +298,7 @@ export default function PageBuilderPage() {
try { try {
const res = await fetch(`${API_BASE}/pages`, { const res = await fetch(`${API_BASE}/pages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: authHeaders(),
body: JSON.stringify({ body: JSON.stringify({
title: newPageData.title, title: newPageData.title,
slug: newPageData.slug.toLowerCase().trim().replace(/\s+/g, '-') slug: newPageData.slug.toLowerCase().trim().replace(/\s+/g, '-')
@@ -316,12 +321,43 @@ export default function PageBuilderPage() {
} }
}; };
const handleSavePageSettings = async () => {
if (!page) return;
setIsSavingPageSettings(true);
try {
const res = await fetch(`${API_BASE}/pages`, {
method: 'POST',
headers: authHeaders(),
body: JSON.stringify({
id: page.id,
slug: page.slug,
title: page.title,
isPublished: page.isPublished
})
});
const data = await res.json();
if (res.ok && data && !data.error) {
setPage((prev) => (prev ? { ...prev, isPublished: data.isPublished ?? prev.isPublished } : prev));
await fetchPagesList();
} else {
alert(data.error || 'Failed to save page settings');
}
} catch (err) {
console.error('Save page settings error:', err);
alert('Failed to save page settings due to network error');
} finally {
setIsSavingPageSettings(false);
}
};
const handleDeletePage = async () => { const handleDeletePage = async () => {
if (!page || page.slug === 'home') return; if (!page || page.slug === 'home') return;
if (!confirm(`Are you sure you want to delete the page "${page.title}"? This will delete all of its section blocks permanently!`)) return; if (!confirm(`Are you sure you want to delete the page "${page.title}"? This will delete all of its section blocks permanently!`)) return;
try { try {
const res = await fetch(`${API_BASE}/pages/${page.id}`, { const res = await fetch(`${API_BASE}/pages/${page.id}`, {
headers: authHeaders(),
method: 'DELETE' method: 'DELETE'
}); });
if (res.ok) { if (res.ok) {
@@ -670,16 +706,31 @@ export default function PageBuilderPage() {
</select> </select>
{page && ( {page && (
<a <>
href={getPageUrl(page.slug)} <a
target="_blank" href={getPageUrl(page.slug)}
rel="noreferrer" target="_blank"
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow hover:bg-cyan-glow/20 text-xs font-bold uppercase tracking-wider transition-all" rel="noreferrer"
title={`Open ${page.title} live preview`} className="inline-flex items-center gap-2 px-3 py-1.5 rounded-xl bg-cyan-glow/10 border border-cyan-glow/20 text-cyan-glow hover:bg-cyan-glow/20 text-xs font-bold uppercase tracking-wider transition-all"
> title={`Open ${page.title} live preview`}
<ExternalLink className="w-4 h-4" /> >
Open Live <ExternalLink className="w-4 h-4" />
</a> Open Live
</a>
<button
type="button"
onClick={handleSavePageSettings}
disabled={isSavingPageSettings}
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-xl bg-white text-night-900 hover:bg-slate-200 disabled:opacity-50 text-xs font-bold uppercase tracking-wider transition-all"
>
{isSavingPageSettings ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
Save Page Settings
</button>
</>
)} )}
<button <button
@@ -698,6 +749,29 @@ export default function PageBuilderPage() {
</button> </button>
)} )}
</div> </div>
{page && (
<div className="mt-4 flex items-center gap-3 rounded-2xl border border-white/10 bg-white/5 px-4 py-3">
<label className="inline-flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={page.isPublished}
onChange={(e) => setPage({ ...page, isPublished: e.target.checked })}
className="h-4 w-4 rounded border-white/20 bg-slate-900 text-cyan-glow focus:ring-cyan-glow"
/>
<span className="text-xs font-bold uppercase tracking-widest text-slate-300">
Publish to Website
</span>
</label>
<span
className={`text-[10px] font-bold uppercase tracking-widest ${
page.isPublished ? 'text-emerald-400' : 'text-amber-400'
}`}
>
{page.isPublished ? 'Published' : 'Draft'}
</span>
</div>
)}
</div> </div>
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
+6 -2
View File
@@ -197,7 +197,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const limit = payload.limit || 3; const limit = payload.limit || 3;
const productInclude = { const productInclude = {
category: true, category: {
include: { translations: true },
},
scent: true, scent: true,
translations: true, translations: true,
variants: { variants: {
@@ -253,7 +255,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
const limit = payload.limit || 3; const limit = payload.limit || 3;
const productInclude = { const productInclude = {
category: true, category: {
include: { translations: true },
},
scent: true, scent: true,
translations: true, translations: true,
variants: { variants: {
+3 -3
View File
@@ -7,7 +7,7 @@ import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer
import ProductCard from '../../../components/ProductCard' import ProductCard from '../../../components/ProductCard'
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '../../../lib/cms' import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '../../../lib/cms'
import { splitCmsLayoutSections } from '../../../lib/cms-sections' import { splitCmsLayoutSections } from '../../../lib/cms-sections'
import { resolveLocalized } from '../../../lib/localization' import { resolveCategoryName, resolveLocalized } from '../../../lib/localization'
const lang = 'all' const lang = 'all'
@@ -24,7 +24,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
if (!product) return { title: 'Product Not Found | HomAroma' } if (!product) return { title: 'Product Not Found | HomAroma' }
const productName = resolveLocalized(product.name, lang) || product.scent.name const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveLocalized(product.category.name, lang) const categoryName = resolveCategoryName(product.category, lang)
const description = resolveLocalized(product.description, lang) const description = resolveLocalized(product.description, lang)
return { return {
@@ -75,7 +75,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
const product = productResult.data const product = productResult.data
const related = await getRelated(product.category.id, product.id) const related = await getRelated(product.category.id, product.id)
const productName = resolveLocalized(product.name, lang) || product.scent.name const productName = resolveLocalized(product.name, lang) || product.scent.name
const categoryName = resolveLocalized(product.category.name, lang) const categoryName = resolveCategoryName(product.category, lang)
const details = resolveLocalized(product.details, lang) const details = resolveLocalized(product.details, lang)
const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName) const imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
@@ -4,8 +4,7 @@ import { useState } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types' import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { resolveLocalized } from '../lib/localization' import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { useTranslation } from 'react-i18next'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
interface Props { interface Props {
@@ -15,11 +14,10 @@ interface Props {
export default function AddToCartSection({ product }: Props) { export default function AddToCartSection({ product }: Props) {
const [selectedVariant, setSelectedVariant] = useState(0) const [selectedVariant, setSelectedVariant] = useState(0)
const { addToCart, toggleCart } = useCartStore() const { addToCart, toggleCart } = useCartStore()
const { t } = useTranslation()
const lang = useLang() const lang = useLang()
const currentVariant = product.variants[selectedVariant] const currentVariant = product.variants[selectedVariant]
const categoryName = resolveLocalized(product.category.name, 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 description = resolveLocalized(product.description, lang) const description = resolveLocalized(product.description, lang)
+2 -2
View File
@@ -3,7 +3,7 @@
import Link from 'next/link' import Link from 'next/link'
import type { Product } from '@homaroma/types' import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types' import { getCategoryImage, formatPrice } from '@homaroma/types'
import { resolveLocalized } from '../lib/localization' import { resolveCategoryName, resolveLocalized } from '../lib/localization'
import { useLang } from '../hooks/useLang' import { useLang } from '../hooks/useLang'
import WishlistButton from './WishlistButton' import WishlistButton from './WishlistButton'
@@ -13,7 +13,7 @@ interface ProductCardProps {
export default function ProductCard({ product }: ProductCardProps) { export default function ProductCard({ product }: ProductCardProps) {
const lang = useLang() const lang = useLang()
const categoryName = resolveLocalized(product.category.name, 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 firstPrice = product.variants[0]?.price ?? product.price
@@ -5,7 +5,7 @@ import { Clock, Mail, MapPin, MessageCircle } from 'lucide-react'
import type { Category, PageSection, Product } from '@homaroma/types' import type { Category, PageSection, Product } from '@homaroma/types'
import { getCategoryImage } from '@homaroma/types' import { getCategoryImage } from '@homaroma/types'
import ProductCard from '../ProductCard' import ProductCard from '../ProductCard'
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization' import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
import { useLang } from '../../hooks/useLang' import { useLang } from '../../hooks/useLang'
type DynamicPageRendererProps = { type DynamicPageRendererProps = {
@@ -15,8 +15,7 @@ type DynamicPageRendererProps = {
} }
function categoryName(category: Category, lang: string) { function categoryName(category: Category, lang: string) {
if (typeof category.name === 'string') return category.name return resolveCategoryName(category, lang)
return category.name[lang] ?? category.name.th ?? category.name.en ?? Object.values(category.name)[0] ?? ''
} }
function getProducts(payload: Record<string, unknown>): Product[] { function getProducts(payload: Record<string, unknown>): Product[] {
@@ -66,6 +65,8 @@ function heroLearnMoreText(lang: string) {
} }
function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const slides = Array.isArray(payload.slides) ? payload.slides : [] const slides = Array.isArray(payload.slides) ? payload.slides : []
if (!slides.length) return null if (!slides.length) return null
@@ -74,7 +75,7 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
{slides.map((slide, index) => ( {slides.map((slide, index) => (
<span key={index} className={index === 0 ? '' : 'hidden md:inline'}> <span key={index} className={index === 0 ? '' : 'hidden md:inline'}>
{index > 0 && <span className="mx-4 text-white/30">/</span>} {index > 0 && <span className="mx-4 text-white/30">/</span>}
{localizedFromRecord({ slide }, 'slide', lang)} {localizedFromRecord({ slide }, 'slide', resolvedLang)}
</span> </span>
))} ))}
</section> </section>
@@ -82,10 +83,12 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
} }
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const tagline = localizedFromRecord(payload, 'tagline', lang) const i18nLang = useLang()
const title = localizedFromRecord(payload, 'title', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const description = localizedFromRecord(payload, 'description', lang) const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', lang) const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png' const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop' const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
@@ -124,7 +127,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
href="#categories" href="#categories"
className="group flex items-center gap-2 px-8 py-4 text-sm font-medium uppercase tracking-widest text-white transition-colors hover:text-[#E4BC32]" className="group flex items-center gap-2 px-8 py-4 text-sm font-medium uppercase tracking-widest text-white transition-colors hover:text-[#E4BC32]"
> >
{heroLearnMoreText(lang)} {heroLearnMoreText(resolvedLang)}
<span className="transition-transform group-hover:translate-x-1">→</span> <span className="transition-transform group-hover:translate-x-1">→</span>
</a> </a>
</div> </div>
@@ -143,12 +146,22 @@ function CategoriesSection({
categories: Category[] categories: Category[]
lang: string lang: string
}) { }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Our Collections' const i18nLang = useLang()
const subtitle = localizedFromRecord(payload, 'subtitle', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Our Collections'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const categoryIds = Array.isArray(payload.categoryIds) ? payload.categoryIds.map(Number) : [] const categoryIds = Array.isArray(payload.categoryIds) ? payload.categoryIds.map(Number) : []
const payloadCategories = getCategoriesFromPayload(payload) const payloadCategories = getCategoriesFromPayload(payload)
const sourceCategories = payloadCategories.length ? payloadCategories : categories const sourceCategories = payloadCategories.length ? payloadCategories : categories
const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories const displayed = categoryIds.length ? sourceCategories.filter((category) => categoryIds.includes(category.id)) : sourceCategories
const productsLabel: Record<string, string> = {
th: 'สินค้า',
en: 'Products',
ja: '商品',
zh: '商品',
vi: 'Sản phẩm',
ko: '상품',
}
return ( return (
<section id="categories" className="bg-white px-6 py-24"> <section id="categories" className="bg-white px-6 py-24">
@@ -159,7 +172,7 @@ function CategoriesSection({
</div> </div>
<div className="grid grid-cols-2 gap-5 md:grid-cols-5"> <div className="grid grid-cols-2 gap-5 md:grid-cols-5">
{displayed.map((category) => { {displayed.map((category) => {
const name = categoryName(category, lang) const name = categoryName(category, resolvedLang)
return ( return (
<Link <Link
@@ -176,7 +189,9 @@ function CategoriesSection({
<div className="absolute bottom-5 left-5 right-5 text-white"> <div className="absolute bottom-5 left-5 right-5 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-white/80">{category._count.products} Products</span> <span className="text-sm text-white/80">
{category._count.products} {productsLabel[resolvedLang] ?? productsLabel.en}
</span>
)} )}
</div> </div>
</Link> </Link>
@@ -189,9 +204,27 @@ function CategoriesSection({
} }
function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Featured Picks' const i18nLang = useLang()
const subtitle = localizedFromRecord(payload, 'subtitle', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Featured Picks'
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const products = getProducts(payload) const products = getProducts(payload)
const viewAllLabel: Record<string, string> = {
th: 'ดูทั้งหมด →',
en: 'View All Collection →',
ja: 'すべて見る →',
zh: '查看全部 →',
vi: 'Xem tất cả →',
ko: '전체 보기 →',
}
const emptyLabel: Record<string, string> = {
th: 'ไม่พบสินค้าในหมวดนี้',
en: 'No products found in this section.',
ja: 'このセクションには商品がありません。',
zh: '此区域中没有找到产品。',
vi: 'Không tìm thấy sản phẩm nào trong mục này.',
ko: '이 섹션에서 찾을 수 있는 상품이 없습니다.',
}
return ( return (
<section className="bg-pop-bg px-6 py-24"> <section className="bg-pop-bg px-6 py-24">
@@ -202,7 +235,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
{subtitle && <p className="mt-2 text-gray-500">{subtitle}</p>} {subtitle && <p className="mt-2 text-gray-500">{subtitle}</p>}
</div> </div>
<Link href="/shop" className="font-semibold text-pop-pink transition hover:text-pop-red"> <Link href="/shop" className="font-semibold text-pop-pink transition hover:text-pop-red">
View All Collection &rarr; {viewAllLabel[resolvedLang] ?? viewAllLabel.en}
</Link> </Link>
</div> </div>
{products.length ? ( {products.length ? (
@@ -211,7 +244,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
</div> </div>
) : ( ) : (
<p className="rounded-2xl bg-white p-8 text-center text-gray-500 shadow-brutal"> <p className="rounded-2xl bg-white p-8 text-center text-gray-500 shadow-brutal">
No products found in this section. {emptyLabel[resolvedLang] ?? emptyLabel.en}
</p> </p>
)} )}
</div> </div>
@@ -220,9 +253,11 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
} }
function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) const i18nLang = useLang()
const description = localizedFromRecord(payload, 'description', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const buttonText = localizedFromRecord(payload, 'buttonText', lang) const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png' const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '/images/hero.png'
const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : '' const linkUrl = typeof payload.linkUrl === 'string' ? payload.linkUrl : ''
@@ -242,8 +277,10 @@ function BannerSection({ payload, lang }: { payload: Record<string, unknown>, la
} }
function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) const i18nLang = useLang()
const content = localizedFromRecord(payload, 'content', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const content = localizedFromRecord(payload, 'content', resolvedLang)
const alignClass = payload.textAlign === 'center' const alignClass = payload.textAlign === 'center'
? 'text-center' ? 'text-center'
: payload.textAlign === 'right' : payload.textAlign === 'right'
@@ -261,9 +298,11 @@ function TextSection({ payload, lang }: { payload: Record<string, unknown>, lang
} }
function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Stay in touch' const i18nLang = useLang()
const description = localizedFromRecord(payload, 'description', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const buttonText = localizedFromRecord(payload, 'buttonText', lang) || 'Subscribe' const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Stay in touch'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang) || 'Subscribe'
const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com' const placeholder = typeof payload.placeholderText === 'string' ? payload.placeholderText : 'you@example.com'
return ( return (
@@ -285,9 +324,11 @@ function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>
} }
function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) const i18nLang = useLang()
const description = localizedFromRecord(payload, 'description', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const buttonText = localizedFromRecord(payload, 'buttonText', lang) const title = localizedFromRecord(payload, 'title', resolvedLang)
const description = localizedFromRecord(payload, 'description', resolvedLang)
const buttonText = localizedFromRecord(payload, 'buttonText', resolvedLang)
const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '' const buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : ''
const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : '' const imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left' const imagePosition = payload.imagePosition === 'right' ? 'right' : 'left'
@@ -326,7 +367,9 @@ function EditorialTileSection({ payload, lang }: { payload: Record<string, unkno
} }
function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function EditorialCarouselSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang)
const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : [] const slides = Array.isArray(payload.slides) ? payload.slides.map(toRecord) : []
if (!slides.length) return null if (!slides.length) return null
@@ -341,10 +384,10 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
)} )}
<div className="grid gap-6 md:grid-cols-3"> <div className="grid gap-6 md:grid-cols-3">
{slides.map((slide, index) => { {slides.map((slide, index) => {
const slideTitle = localizedFromRecord(slide, 'title', lang) const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
const description = localizedFromRecord(slide, 'description', lang) const description = localizedFromRecord(slide, 'description', resolvedLang)
const category = localizedFromRecord(slide, 'category', lang) const category = localizedFromRecord(slide, 'category', resolvedLang)
const buttonText = localizedFromRecord(slide, 'buttonText', lang) const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : '' const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : '' const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
@@ -375,10 +418,12 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
} }
function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Guide Article' const i18nLang = useLang()
const subtitle = localizedFromRecord(payload, 'subtitle', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const author = localizedFromRecord(payload, 'author', lang) const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Guide Article'
const category = localizedFromRecord(payload, 'category', lang) const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const category = localizedFromRecord(payload, 'category', resolvedLang)
const imageUrl = typeof payload.imageUrl === 'string' const imageUrl = typeof payload.imageUrl === 'string'
? payload.imageUrl ? payload.imageUrl
: typeof payload.backgroundImageUrl === 'string' : typeof payload.backgroundImageUrl === 'string'
@@ -389,6 +434,14 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
: typeof payload.publishDate === 'string' : typeof payload.publishDate === 'string'
? payload.publishDate ? payload.publishDate
: '' : ''
const authorPrefix: Record<string, string> = {
th: 'โดย',
en: 'By',
ja: '著者',
zh: '作者',
vi: 'Bởi',
ko: '작성자',
}
return ( return (
<section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-pop-black px-6 py-24 text-center text-white md:min-h-[76vh]"> <section className="relative flex min-h-[60vh] items-center justify-center overflow-hidden bg-pop-black px-6 py-24 text-center text-white md:min-h-[76vh]">
@@ -404,7 +457,7 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
{subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>} {subtitle && <p className="mx-auto max-w-2xl text-lg text-white/85 md:text-xl">{subtitle}</p>}
{(author || date) && ( {(author || date) && (
<p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70"> <p className="text-xs font-bold uppercase tracking-[0.22em] text-white/70">
{[author && `By ${author}`, date].filter(Boolean).join(' / ')} {[author && `${authorPrefix[resolvedLang] ?? authorPrefix.en} ${author}`, date].filter(Boolean).join(' / ')}
</p> </p>
)} )}
</div> </div>
@@ -413,12 +466,23 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
} }
function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Notes' const i18nLang = useLang()
const description = localizedFromRecord(payload, 'description', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Notes'
const description = localizedFromRecord(payload, 'description', resolvedLang)
const noteLabels: Record<string, Record<string, string>> = {
th: { top: 'กลิ่นเปิด', heart: 'กลิ่นกลาง', base: 'กลิ่นฐาน' },
en: { top: 'Top Notes', heart: 'Heart Notes', base: 'Base Notes' },
ja: { top: 'トップノート', heart: 'ミドルノート', base: 'ベースノート' },
zh: { top: '前调', heart: '中调', base: '后调' },
vi: { top: 'Hương đầu', heart: 'Hương giữa', base: 'Hương cuối' },
ko: { top: '탑 노트', heart: '미들 노트', base: '베이스 노트' },
}
const labels = noteLabels[resolvedLang] ?? noteLabels.en
const notes = [ const notes = [
['Top Notes', optionalText(payload.topNotes, lang) || 'The initial, lighter smells of the fragrance.'], [labels.top, optionalText(payload.topNotes, resolvedLang) || 'The initial, lighter smells of the fragrance.'],
['Heart Notes', optionalText(payload.heartNotes ?? payload.middleNotes, lang) || 'The core of the fragrance, emerging just after the top notes.'], [labels.heart, optionalText(payload.heartNotes ?? payload.middleNotes, resolvedLang) || 'The core of the fragrance, emerging just after the top notes.'],
['Base Notes', optionalText(payload.baseNotes, lang) || 'The deepest, richest scents that linger the longest.'], [labels.base, optionalText(payload.baseNotes, resolvedLang) || 'The deepest, richest scents that linger the longest.'],
] ]
return ( return (
@@ -442,9 +506,11 @@ function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string,
} }
function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) { function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const quote = localizedFromRecord(payload, 'quote', lang) const i18nLang = useLang()
const author = localizedFromRecord(payload, 'author', lang) const resolvedLang = lang === 'all' ? i18nLang : lang
const role = localizedFromRecord(payload, 'role', lang) const quote = localizedFromRecord(payload, 'quote', resolvedLang)
const author = localizedFromRecord(payload, 'author', resolvedLang)
const role = localizedFromRecord(payload, 'role', resolvedLang)
if (!quote) return null if (!quote) return null
return ( return (
@@ -464,16 +530,20 @@ function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknow
) )
} }
function AccordionSection({ payload }: { payload: Record<string, unknown> }) { function AccordionSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : [] const categories = Array.isArray(payload.categories) ? payload.categories.map(toRecord) : []
if (!categories.length) return null if (!categories.length) return null
const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang)
return ( return (
<section className="bg-pop-bg px-6 py-20"> <section className="bg-pop-bg px-6 py-20">
<div className="mx-auto max-w-4xl space-y-10"> <div className="mx-auto max-w-4xl space-y-10">
{sectionTitle && <h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{sectionTitle}</h2>}
{categories.map((category, categoryIndex) => { {categories.map((category, categoryIndex) => {
const questions = Array.isArray(category.questions) ? category.questions.map(toRecord) : [] const questions = Array.isArray(category.questions) ? category.questions.map(toRecord) : []
const name = typeof category.name === 'string' ? category.name : '' const name = localizedFromRecord(category, 'name', resolvedLang)
const icon = typeof category.icon === 'string' ? category.icon : '' const icon = typeof category.icon === 'string' ? category.icon : ''
return ( return (
@@ -486,8 +556,8 @@ function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
)} )}
<div className="space-y-4"> <div className="space-y-4">
{questions.map((item, itemIndex) => { {questions.map((item, itemIndex) => {
const question = typeof item.q === 'string' ? item.q : '' const question = localizedFromRecord(item, 'q', resolvedLang)
const answer = typeof item.a === 'string' ? item.a : '' const answer = localizedFromRecord(item, 'a', resolvedLang)
if (!question || !answer) return null if (!question || !answer) return null
return ( return (
@@ -508,27 +578,48 @@ function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
) )
} }
function ContactInfoSection({ payload }: { payload: Record<string, unknown> }) { function ContactInfoSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
const i18nLang = useLang()
const resolvedLang = lang === 'all' ? i18nLang : lang
const labels: Record<string, Record<string, string>> = {
th: { address: 'ที่อยู่', email: 'อีเมล', line: 'Line', hours: 'เวลาทำการ' },
en: { address: 'Address', email: 'Email', line: 'Line', hours: 'Business Hours' },
ja: { address: '住所', email: 'メール', line: 'Line', hours: '営業時間' },
zh: { address: '地址', email: '电子邮件', line: 'Line', hours: '营业时间' },
vi: { address: 'Địa chỉ', email: 'Email', line: 'Line', hours: 'Giờ làm việc' },
ko: { address: '주소', email: '이메일', line: 'Line', hours: '운영 시간' },
}
const resolvedLabels = labels[resolvedLang] ?? labels.en
const title = localizedFromRecord(payload, 'title', resolvedLang)
const address = localizedFromRecord(payload, 'address', resolvedLang)
const email = typeof payload.email === 'string' ? payload.email : ''
const line = typeof payload.line === 'string' ? payload.line : ''
const hours = localizedFromRecord(payload, 'hours', resolvedLang)
const items = [ const items = [
['ที่อยู่', typeof payload.address === 'string' ? payload.address : '', MapPin], { key: 'address', label: resolvedLabels.address, value: address, Icon: MapPin },
['อีเมล', typeof payload.email === 'string' ? payload.email : '', Mail], { key: 'email', label: resolvedLabels.email, value: email, Icon: Mail },
['Line', typeof payload.line === 'string' ? payload.line : '', MessageCircle], { key: 'line', label: resolvedLabels.line, value: line, Icon: MessageCircle },
['เวลาทำการ', typeof payload.hours === 'string' ? payload.hours : '', Clock], { key: 'hours', label: resolvedLabels.hours, value: hours, Icon: Clock },
] as const ] as const
return ( return (
<section className="bg-white px-6 py-20"> <section className="bg-white px-6 py-20">
<div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-4"> <div className="mx-auto grid max-w-6xl gap-6 md:grid-cols-2 lg:grid-cols-4">
{items.map(([label, value, Icon]) => { {title && (
<div className="col-span-full mb-2">
<h2 className="text-4xl font-semibold text-pop-black md:text-5xl">{title}</h2>
</div>
)}
{items.map(({ key, label, value, Icon }) => {
if (!value) return null if (!value) return null
return ( return (
<article key={label} className="rounded-3xl bg-pop-bg p-6 text-center shadow-brutal"> <article key={key} className="rounded-3xl bg-pop-bg p-6 text-center shadow-brutal">
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white text-pop-pink"> <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-white text-pop-pink">
<Icon className="h-6 w-6" /> <Icon className="h-6 w-6" />
</div> </div>
<h3 className="mb-2 text-lg font-semibold text-pop-black">{label}</h3> <h3 className="mb-2 text-lg font-semibold text-pop-black">{label}</h3>
{label === 'อีเมล' ? ( {key === 'email' ? (
<a className="break-words text-gray-600 transition hover:text-pop-pink" href={`mailto:${value}`}> <a className="break-words text-gray-600 transition hover:text-pop-pink" href={`mailto:${value}`}>
{value} {value}
</a> </a>
@@ -584,9 +675,9 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
case 'QUOTE_CALLOUT': case 'QUOTE_CALLOUT':
return <QuoteCalloutSection key={section.id} payload={payload} lang={lang} /> return <QuoteCalloutSection key={section.id} payload={payload} lang={lang} />
case 'ACCORDION': case 'ACCORDION':
return <AccordionSection key={section.id} payload={payload} /> return <AccordionSection key={section.id} payload={payload} lang={lang} />
case 'CONTACT_INFO': case 'CONTACT_INFO':
return <ContactInfoSection key={section.id} payload={payload} /> return <ContactInfoSection 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}`)
+14 -1
View File
@@ -1,4 +1,4 @@
import type { TranslatableString } from '@homaroma/types' import type { Category, TranslatableString } from '@homaroma/types'
export function resolveLocalized(value: TranslatableString | null | undefined, lang = 'th'): string { export function resolveLocalized(value: TranslatableString | null | undefined, lang = 'th'): string {
if (!value) return '' if (!value) return ''
@@ -14,3 +14,16 @@ export function toRecord(value: unknown): Record<string, unknown> {
export function localizedFromRecord(payload: Record<string, unknown>, key: string, lang = 'th'): string { export function localizedFromRecord(payload: Record<string, unknown>, key: string, lang = 'th'): string {
return resolveLocalized(payload[key] as TranslatableString | null | undefined, lang) return resolveLocalized(payload[key] as TranslatableString | null | undefined, lang)
} }
export function resolveCategoryName(category: Pick<Category, 'name' | 'translations'>, lang = 'th'): string {
if (category.translations?.length) {
const translated = category.translations.find((tr) => tr.langCode === lang)
?? category.translations.find((tr) => tr.langCode === lang.slice(0, 2))
?? category.translations.find((tr) => tr.langCode === 'th')
?? category.translations[0]
if (translated?.name) return translated.name
}
return resolveLocalized(category.name, lang)
}