x
This commit is contained in:
1 parent
62db477dbd
commit
feeda6dc60
7 files changed
+263
-83
No files matched your search
@@ -44,6 +44,10 @@ interface Scent {
|
||||
|
||||
export default function PageBuilderPage() {
|
||||
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 [sections, setSections] = useState<PageSection[]>([]);
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
@@ -53,6 +57,7 @@ export default function PageBuilderPage() {
|
||||
const [editingSection, setEditingSection] = useState<Partial<PageSection> | null>(null);
|
||||
const [uploadTarget, setUploadTarget] = useState<{ type: 'main' | 'slide'; index?: number } | null>(null);
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
||||
const [isSavingPageSettings, setIsSavingPageSettings] = useState(false);
|
||||
|
||||
const [isMediaModalOpen, setIsMediaModalOpen] = useState(false);
|
||||
|
||||
@@ -293,7 +298,7 @@ export default function PageBuilderPage() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
headers: authHeaders(),
|
||||
body: JSON.stringify({
|
||||
title: newPageData.title,
|
||||
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 () => {
|
||||
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;
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/pages/${page.id}`, {
|
||||
headers: authHeaders(),
|
||||
method: 'DELETE'
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -670,16 +706,31 @@ export default function PageBuilderPage() {
|
||||
</select>
|
||||
|
||||
{page && (
|
||||
<a
|
||||
href={getPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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
|
||||
</a>
|
||||
<>
|
||||
<a
|
||||
href={getPageUrl(page.slug)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
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
|
||||
</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
|
||||
@@ -698,6 +749,29 @@ export default function PageBuilderPage() {
|
||||
</button>
|
||||
)}
|
||||
</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 className="flex gap-4">
|
||||
<button
|
||||
|
||||
@@ -197,7 +197,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
const limit = payload.limit || 3;
|
||||
|
||||
const productInclude = {
|
||||
category: true,
|
||||
category: {
|
||||
include: { translations: true },
|
||||
},
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
@@ -253,7 +255,9 @@ export const pagesRoute = new Elysia({ prefix: "/pages" })
|
||||
const limit = payload.limit || 3;
|
||||
|
||||
const productInclude = {
|
||||
category: true,
|
||||
category: {
|
||||
include: { translations: true },
|
||||
},
|
||||
scent: true,
|
||||
translations: true,
|
||||
variants: {
|
||||
|
||||
@@ -7,7 +7,7 @@ import DynamicPageRenderer from '../../../components/dynamic/DynamicPageRenderer
|
||||
import ProductCard from '../../../components/ProductCard'
|
||||
import { getCategoriesResult, getCmsPageResult, getProduct, getProductResult, getProductsResult } from '../../../lib/cms'
|
||||
import { splitCmsLayoutSections } from '../../../lib/cms-sections'
|
||||
import { resolveLocalized } from '../../../lib/localization'
|
||||
import { resolveCategoryName, resolveLocalized } from '../../../lib/localization'
|
||||
|
||||
const lang = 'all'
|
||||
|
||||
@@ -24,7 +24,7 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
|
||||
if (!product) return { title: 'Product Not Found | HomAroma' }
|
||||
|
||||
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)
|
||||
|
||||
return {
|
||||
@@ -75,7 +75,7 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
|
||||
const product = productResult.data
|
||||
const related = await getRelated(product.category.id, product.id)
|
||||
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 imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@ import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { resolveLocalized } from '../lib/localization'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { resolveCategoryName, resolveLocalized } from '../lib/localization'
|
||||
import { useLang } from '../hooks/useLang'
|
||||
|
||||
interface Props {
|
||||
@@ -15,11 +14,10 @@ interface Props {
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
const { t } = useTranslation()
|
||||
const lang = useLang()
|
||||
|
||||
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 description = resolveLocalized(product.description, lang)
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import type { Product } 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 WishlistButton from './WishlistButton'
|
||||
|
||||
@@ -13,7 +13,7 @@ interface ProductCardProps {
|
||||
|
||||
export default function ProductCard({ product }: ProductCardProps) {
|
||||
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 imageUrl = product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(categoryName)
|
||||
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 { getCategoryImage } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
import { localizedFromRecord, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord } from '../../lib/localization'
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
|
||||
type DynamicPageRendererProps = {
|
||||
@@ -15,8 +15,7 @@ type DynamicPageRendererProps = {
|
||||
}
|
||||
|
||||
function categoryName(category: Category, lang: string) {
|
||||
if (typeof category.name === 'string') return category.name
|
||||
return category.name[lang] ?? category.name.th ?? category.name.en ?? Object.values(category.name)[0] ?? ''
|
||||
return resolveCategoryName(category, lang)
|
||||
}
|
||||
|
||||
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 }) {
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const slides = Array.isArray(payload.slides) ? payload.slides : []
|
||||
if (!slides.length) return null
|
||||
|
||||
@@ -74,7 +75,7 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
|
||||
{slides.map((slide, index) => (
|
||||
<span key={index} className={index === 0 ? '' : 'hidden md:inline'}>
|
||||
{index > 0 && <span className="mx-4 text-white/30">/</span>}
|
||||
{localizedFromRecord({ slide }, 'slide', lang)}
|
||||
{localizedFromRecord({ slide }, 'slide', resolvedLang)}
|
||||
</span>
|
||||
))}
|
||||
</section>
|
||||
@@ -82,10 +83,12 @@ function AnnouncementBar({ payload, lang }: { payload: Record<string, unknown>,
|
||||
}
|
||||
|
||||
function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const tagline = localizedFromRecord(payload, 'tagline', lang)
|
||||
const title = localizedFromRecord(payload, 'title', lang)
|
||||
const description = localizedFromRecord(payload, 'description', lang)
|
||||
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const tagline = localizedFromRecord(payload, 'tagline', resolvedLang)
|
||||
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 buttonLink = typeof payload.buttonLink === 'string' ? payload.buttonLink : '/shop'
|
||||
|
||||
@@ -124,7 +127,7 @@ function HeroSection({ payload, lang }: { payload: Record<string, unknown>, lang
|
||||
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]"
|
||||
>
|
||||
{heroLearnMoreText(lang)}
|
||||
{heroLearnMoreText(resolvedLang)}
|
||||
<span className="transition-transform group-hover:translate-x-1">→</span>
|
||||
</a>
|
||||
</div>
|
||||
@@ -143,12 +146,22 @@ function CategoriesSection({
|
||||
categories: Category[]
|
||||
lang: string
|
||||
}) {
|
||||
const title = localizedFromRecord(payload, 'title', lang) || 'Our Collections'
|
||||
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
|
||||
const i18nLang = useLang()
|
||||
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 payloadCategories = getCategoriesFromPayload(payload)
|
||||
const sourceCategories = payloadCategories.length ? payloadCategories : categories
|
||||
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 (
|
||||
<section id="categories" className="bg-white px-6 py-24">
|
||||
@@ -159,7 +172,7 @@ function CategoriesSection({
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-5 md:grid-cols-5">
|
||||
{displayed.map((category) => {
|
||||
const name = categoryName(category, lang)
|
||||
const name = categoryName(category, resolvedLang)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -176,7 +189,9 @@ function CategoriesSection({
|
||||
<div className="absolute bottom-5 left-5 right-5 text-white">
|
||||
<span className="block text-lg font-semibold">{name}</span>
|
||||
{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>
|
||||
</Link>
|
||||
@@ -189,9 +204,27 @@ function CategoriesSection({
|
||||
}
|
||||
|
||||
function ProductsSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang) || 'Featured Picks'
|
||||
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
|
||||
const i18nLang = useLang()
|
||||
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 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 (
|
||||
<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>}
|
||||
</div>
|
||||
<Link href="/shop" className="font-semibold text-pop-pink transition hover:text-pop-red">
|
||||
View All Collection →
|
||||
{viewAllLabel[resolvedLang] ?? viewAllLabel.en}
|
||||
</Link>
|
||||
</div>
|
||||
{products.length ? (
|
||||
@@ -211,7 +244,7 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -220,9 +253,11 @@ function ProductsSection({ payload, lang }: { payload: Record<string, unknown>,
|
||||
}
|
||||
|
||||
function BannerSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang)
|
||||
const description = localizedFromRecord(payload, 'description', lang)
|
||||
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : 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 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 }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang)
|
||||
const content = localizedFromRecord(payload, 'content', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const title = localizedFromRecord(payload, 'title', resolvedLang)
|
||||
const content = localizedFromRecord(payload, 'content', resolvedLang)
|
||||
const alignClass = payload.textAlign === 'center'
|
||||
? 'text-center'
|
||||
: 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 }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang) || 'Stay in touch'
|
||||
const description = localizedFromRecord(payload, 'description', lang)
|
||||
const buttonText = localizedFromRecord(payload, 'buttonText', lang) || 'Subscribe'
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
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'
|
||||
|
||||
return (
|
||||
@@ -285,9 +324,11 @@ function NewsletterSection({ payload, lang }: { payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
function EditorialTileSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang)
|
||||
const description = localizedFromRecord(payload, 'description', lang)
|
||||
const buttonText = localizedFromRecord(payload, 'buttonText', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : 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 imageUrl = typeof payload.imageUrl === 'string' ? payload.imageUrl : ''
|
||||
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 }) {
|
||||
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) : []
|
||||
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">
|
||||
{slides.map((slide, index) => {
|
||||
const slideTitle = localizedFromRecord(slide, 'title', lang)
|
||||
const description = localizedFromRecord(slide, 'description', lang)
|
||||
const category = localizedFromRecord(slide, 'category', lang)
|
||||
const buttonText = localizedFromRecord(slide, 'buttonText', lang)
|
||||
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
|
||||
const description = localizedFromRecord(slide, 'description', resolvedLang)
|
||||
const category = localizedFromRecord(slide, 'category', resolvedLang)
|
||||
const buttonText = localizedFromRecord(slide, 'buttonText', resolvedLang)
|
||||
const buttonLink = typeof slide.buttonLink === 'string' ? slide.buttonLink : ''
|
||||
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 }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Guide Article'
|
||||
const subtitle = localizedFromRecord(payload, 'subtitle', lang)
|
||||
const author = localizedFromRecord(payload, 'author', lang)
|
||||
const category = localizedFromRecord(payload, 'category', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const title = localizedFromRecord(payload, 'title', resolvedLang) || 'Scent Guide Article'
|
||||
const subtitle = localizedFromRecord(payload, 'subtitle', resolvedLang)
|
||||
const author = localizedFromRecord(payload, 'author', resolvedLang)
|
||||
const category = localizedFromRecord(payload, 'category', resolvedLang)
|
||||
const imageUrl = typeof payload.imageUrl === 'string'
|
||||
? payload.imageUrl
|
||||
: typeof payload.backgroundImageUrl === 'string'
|
||||
@@ -389,6 +434,14 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
|
||||
: typeof payload.publishDate === 'string'
|
||||
? payload.publishDate
|
||||
: ''
|
||||
const authorPrefix: Record<string, string> = {
|
||||
th: 'โดย',
|
||||
en: 'By',
|
||||
ja: '著者',
|
||||
zh: '作者',
|
||||
vi: 'Bởi',
|
||||
ko: '작성자',
|
||||
}
|
||||
|
||||
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]">
|
||||
@@ -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>}
|
||||
{(author || date) && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
@@ -413,12 +466,23 @@ function HeroArticleSection({ payload, lang }: { payload: Record<string, unknown
|
||||
}
|
||||
|
||||
function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const title = localizedFromRecord(payload, 'title', lang) || 'Scent Notes'
|
||||
const description = localizedFromRecord(payload, 'description', lang)
|
||||
const i18nLang = useLang()
|
||||
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 = [
|
||||
['Top Notes', optionalText(payload.topNotes, lang) || '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.'],
|
||||
['Base Notes', optionalText(payload.baseNotes, lang) || 'The deepest, richest scents that linger the longest.'],
|
||||
[labels.top, optionalText(payload.topNotes, resolvedLang) || 'The initial, lighter smells of the fragrance.'],
|
||||
[labels.heart, optionalText(payload.heartNotes ?? payload.middleNotes, resolvedLang) || 'The core of the fragrance, emerging just after the top notes.'],
|
||||
[labels.base, optionalText(payload.baseNotes, resolvedLang) || 'The deepest, richest scents that linger the longest.'],
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -442,9 +506,11 @@ function ScentNotesBreakdownSection({ payload, lang }: { payload: Record<string,
|
||||
}
|
||||
|
||||
function QuoteCalloutSection({ payload, lang }: { payload: Record<string, unknown>, lang: string }) {
|
||||
const quote = localizedFromRecord(payload, 'quote', lang)
|
||||
const author = localizedFromRecord(payload, 'author', lang)
|
||||
const role = localizedFromRecord(payload, 'role', lang)
|
||||
const i18nLang = useLang()
|
||||
const resolvedLang = lang === 'all' ? i18nLang : lang
|
||||
const quote = localizedFromRecord(payload, 'quote', resolvedLang)
|
||||
const author = localizedFromRecord(payload, 'author', resolvedLang)
|
||||
const role = localizedFromRecord(payload, 'role', resolvedLang)
|
||||
if (!quote) return null
|
||||
|
||||
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) : []
|
||||
if (!categories.length) return null
|
||||
const sectionTitle = localizedFromRecord(payload, 'title', resolvedLang)
|
||||
|
||||
return (
|
||||
<section className="bg-pop-bg px-6 py-20">
|
||||
<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) => {
|
||||
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 : ''
|
||||
|
||||
return (
|
||||
@@ -486,8 +556,8 @@ function AccordionSection({ payload }: { payload: Record<string, unknown> }) {
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
{questions.map((item, itemIndex) => {
|
||||
const question = typeof item.q === 'string' ? item.q : ''
|
||||
const answer = typeof item.a === 'string' ? item.a : ''
|
||||
const question = localizedFromRecord(item, 'q', resolvedLang)
|
||||
const answer = localizedFromRecord(item, 'a', resolvedLang)
|
||||
if (!question || !answer) return null
|
||||
|
||||
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 = [
|
||||
['ที่อยู่', typeof payload.address === 'string' ? payload.address : '', MapPin],
|
||||
['อีเมล', typeof payload.email === 'string' ? payload.email : '', Mail],
|
||||
['Line', typeof payload.line === 'string' ? payload.line : '', MessageCircle],
|
||||
['เวลาทำการ', typeof payload.hours === 'string' ? payload.hours : '', Clock],
|
||||
{ key: 'address', label: resolvedLabels.address, value: address, Icon: MapPin },
|
||||
{ key: 'email', label: resolvedLabels.email, value: email, Icon: Mail },
|
||||
{ key: 'line', label: resolvedLabels.line, value: line, Icon: MessageCircle },
|
||||
{ key: 'hours', label: resolvedLabels.hours, value: hours, Icon: Clock },
|
||||
] as const
|
||||
|
||||
return (
|
||||
<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">
|
||||
{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
|
||||
|
||||
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">
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<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}`}>
|
||||
{value}
|
||||
</a>
|
||||
@@ -584,9 +675,9 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
|
||||
case 'QUOTE_CALLOUT':
|
||||
return <QuoteCalloutSection key={section.id} payload={payload} lang={lang} />
|
||||
case 'ACCORDION':
|
||||
return <AccordionSection key={section.id} payload={payload} />
|
||||
return <AccordionSection key={section.id} payload={payload} lang={lang} />
|
||||
case 'CONTACT_INFO':
|
||||
return <ContactInfoSection key={section.id} payload={payload} />
|
||||
return <ContactInfoSection key={section.id} payload={payload} lang={lang} />
|
||||
default:
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
|
||||
|
||||
@@ -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 {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
Reference in new issue
Block a user