From feeda6dc60d3903dd74526012429ee7efd75bac6 Mon Sep 17 00:00:00 2001
From: Your Name
Date: Thu, 18 Jun 2026 20:44:38 -0400
Subject: [PATCH] x
---
apps/admin/src/pages/PageBuilderPage.tsx | 96 +++++++-
apps/backend/src/routes/pages.ts | 8 +-
apps/website/src/app/products/[id]/page.tsx | 6 +-
.../src/components/AddToCartSection.tsx | 6 +-
apps/website/src/components/ProductCard.tsx | 4 +-
.../dynamic/DynamicPageRenderer.tsx | 211 +++++++++++++-----
apps/website/src/lib/localization.ts | 15 +-
7 files changed, 263 insertions(+), 83 deletions(-)
diff --git a/apps/admin/src/pages/PageBuilderPage.tsx b/apps/admin/src/pages/PageBuilderPage.tsx
index 5b8f7c5..0895649 100644
--- a/apps/admin/src/pages/PageBuilderPage.tsx
+++ b/apps/admin/src/pages/PageBuilderPage.tsx
@@ -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(null);
const [sections, setSections] = useState([]);
const [categories, setCategories] = useState([]);
@@ -53,6 +57,7 @@ export default function PageBuilderPage() {
const [editingSection, setEditingSection] = useState | 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() {
{page && (
-
-
- Open Live
-
+ <>
+
+
+ Open Live
+
+
+ >
)}
}
{(author || date) && (
- {[author && `By ${author}`, date].filter(Boolean).join(' / ')}
+ {[author && `${authorPrefix[resolvedLang] ?? authorPrefix.en} ${author}`, date].filter(Boolean).join(' / ')}
)}
@@ -413,12 +466,23 @@ function HeroArticleSection({ payload, lang }: { payload: Record, 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> = {
+ 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, 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 }) {
+function AccordionSection({ payload, lang }: { payload: Record, 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 (
+ {sectionTitle &&
{sectionTitle}
}
{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
}) {
)}
{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
}) {
)
}
-function ContactInfoSection({ payload }: { payload: Record }) {
+function ContactInfoSection({ payload, lang }: { payload: Record, lang: string }) {
+ const i18nLang = useLang()
+ const resolvedLang = lang === 'all' ? i18nLang : lang
+ const labels: Record> = {
+ 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 (
- {items.map(([label, value, Icon]) => {
+ {title && (
+
+
{title}
+
+ )}
+ {items.map(({ key, label, value, Icon }) => {
if (!value) return null
return (
-
+
{label}
- {label === 'อีเมล' ? (
+ {key === 'email' ? (
{value}
@@ -584,9 +675,9 @@ export default function DynamicPageRenderer({ sections, categories, lang: langPr
case 'QUOTE_CALLOUT':
return
case 'ACCORDION':
- return
+ return
case 'CONTACT_INFO':
- return
+ return
default:
if (process.env.NODE_ENV !== 'production') {
console.warn(`[DynamicPageRenderer] Unsupported section type: ${section.type}`)
diff --git a/apps/website/src/lib/localization.ts b/apps/website/src/lib/localization.ts
index c9f7136..123bf54 100644
--- a/apps/website/src/lib/localization.ts
+++ b/apps/website/src/lib/localization.ts
@@ -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 {
export function localizedFromRecord(payload: Record, key: string, lang = 'th'): string {
return resolveLocalized(payload[key] as TranslatableString | null | undefined, lang)
}
+
+export function resolveCategoryName(category: Pick, 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)
+}