From aefa13e363fce5be6b1f40c462c3d664562fa77f Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 20 Jun 2026 20:33:34 -0400 Subject: [PATCH] x --- .../src/app/[lang]/[slug]/[...rest]/page.tsx | 10 ++ apps/website/src/app/[lang]/not-found.tsx | 11 ++ .../src/components/LocalizedNotFound.tsx | 163 ++++++++++++++++++ .../dynamic/DynamicPageRenderer.tsx | 2 +- apps/website/src/proxy.ts | 13 +- packages/types/index.ts | 27 +-- 6 files changed, 209 insertions(+), 17 deletions(-) create mode 100644 apps/website/src/app/[lang]/[slug]/[...rest]/page.tsx create mode 100644 apps/website/src/app/[lang]/not-found.tsx create mode 100644 apps/website/src/components/LocalizedNotFound.tsx diff --git a/apps/website/src/app/[lang]/[slug]/[...rest]/page.tsx b/apps/website/src/app/[lang]/[slug]/[...rest]/page.tsx new file mode 100644 index 0000000..4033cbd --- /dev/null +++ b/apps/website/src/app/[lang]/[slug]/[...rest]/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from 'next' +import { notFound } from 'next/navigation' + +export const metadata: Metadata = { + title: 'Page Not Found | HomAroma', +} + +export default function NestedLocaleNotFound() { + notFound() +} diff --git a/apps/website/src/app/[lang]/not-found.tsx b/apps/website/src/app/[lang]/not-found.tsx new file mode 100644 index 0000000..405b916 --- /dev/null +++ b/apps/website/src/app/[lang]/not-found.tsx @@ -0,0 +1,11 @@ +import { headers } from 'next/headers' +import LocalizedNotFound from '@/components/LocalizedNotFound' +import { DEFAULT_LOCALE, isSupportedLocale } from '@/lib/locales' + +export default async function NotFound() { + const requestHeaders = await headers() + const headerLocale = requestHeaders.get('x-homaroma-locale') ?? DEFAULT_LOCALE + const lang = isSupportedLocale(headerLocale) ? headerLocale : DEFAULT_LOCALE + + return +} diff --git a/apps/website/src/components/LocalizedNotFound.tsx b/apps/website/src/components/LocalizedNotFound.tsx new file mode 100644 index 0000000..9fe7681 --- /dev/null +++ b/apps/website/src/components/LocalizedNotFound.tsx @@ -0,0 +1,163 @@ +import Image from 'next/image' +import Link from 'next/link' +import { ArrowRight, Home, Search, ShoppingBag } from 'lucide-react' +import { getPathWithLocale, type Locale } from '@/lib/locales' + +const copy = { + th: { + eyebrow: 'ไม่พบหน้านี้', + title: 'เส้นทางนี้ยังไม่มีกลิ่นที่ตามหา', + description: 'ลิงก์อาจถูกย้าย ลบ หรือยังไม่ได้เผยแพร่ ลองกลับไปเลือกสินค้าหรือค้นหากลิ่นที่เหมาะกับพื้นที่ของคุณ', + home: 'กลับหน้าแรก', + shop: 'เลือกสินค้า', + search: 'ค้นหาสินค้า', + guide: 'คู่มือเลือกกลิ่น', + contact: 'ติดต่อเรา', + hint: '404', + }, + en: { + eyebrow: 'Page not found', + title: 'This scent trail has faded', + description: 'The link may have moved, expired, or is not published yet. Head back to the collection or search for the right aroma.', + home: 'Back home', + shop: 'Shop products', + search: 'Search products', + guide: 'Scent Guide', + contact: 'Contact', + hint: '404', + }, + ja: { + eyebrow: 'ページが見つかりません', + title: 'お探しの香りのページは見つかりません', + description: 'リンクが移動、削除、または未公開の可能性があります。コレクションに戻るか商品を検索してください。', + home: 'ホームへ戻る', + shop: '商品を見る', + search: '商品を検索', + guide: '香りガイド', + contact: 'お問い合わせ', + hint: '404', + }, + zh: { + eyebrow: '页面未找到', + title: '这条香气路径暂时不存在', + description: '链接可能已移动、删除或尚未发布。你可以返回首页、浏览商品或搜索合适的香氛。', + home: '返回首页', + shop: '浏览商品', + search: '搜索商品', + guide: '香氛指南', + contact: '联系我们', + hint: '404', + }, + vi: { + eyebrow: 'Không tìm thấy trang', + title: 'Đường dẫn hương thơm này không còn tồn tại', + description: 'Liên kết có thể đã được di chuyển, xóa hoặc chưa xuất bản. Hãy quay lại bộ sưu tập hoặc tìm sản phẩm phù hợp.', + home: 'Về trang chủ', + shop: 'Xem sản phẩm', + search: 'Tìm sản phẩm', + guide: 'Hướng dẫn mùi hương', + contact: 'Liên hệ', + hint: '404', + }, + ko: { + eyebrow: '페이지를 찾을 수 없습니다', + title: '찾으시는 향의 경로가 사라졌습니다', + description: '링크가 이동되었거나 삭제되었거나 아직 공개되지 않았을 수 있습니다. 컬렉션으로 돌아가거나 상품을 검색해 보세요.', + home: '홈으로', + shop: '상품 보기', + search: '상품 검색', + guide: '향 가이드', + contact: '문의하기', + hint: '404', + }, +} as const + +type CopyLang = keyof typeof copy + +function getCopy(lang: string) { + return copy[(lang in copy ? lang : 'th') as CopyLang] +} + +type LocalizedNotFoundProps = { + lang: Locale +} + +export default function LocalizedNotFound({ lang }: LocalizedNotFoundProps) { + const text = getCopy(lang) + + return ( +
+
+
+
+ + {text.eyebrow} +
+ +

{text.hint}

+

+ {text.title} +

+

+ {text.description} +

+ +
+ + + {text.home} + + + + {text.shop} + +
+
+ +
+
+
+
+ +
+
+

{text.hint}

+

{text.search}

+
+
+ +
+ + {text.search} + + + + {text.guide} + + + + {text.contact} + + +
+
+
+
+
+ ) +} diff --git a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx index b57134b..95c9d02 100644 --- a/apps/website/src/components/dynamic/DynamicPageRenderer.tsx +++ b/apps/website/src/components/dynamic/DynamicPageRenderer.tsx @@ -34,7 +34,7 @@ function localizedValue(value: unknown, lang: string) { } function optionalText(value: unknown, lang: string) { - if (Array.isArray(value)) return value.filter(Boolean).join(', ') + if (Array.isArray(value)) return value.map(v => localizedValue(v, lang)).filter(Boolean).join(', ') return localizedValue(value, lang) } diff --git a/apps/website/src/proxy.ts b/apps/website/src/proxy.ts index d283023..21aa085 100644 --- a/apps/website/src/proxy.ts +++ b/apps/website/src/proxy.ts @@ -18,12 +18,19 @@ export function proxy(request: NextRequest) { } // Check if the pathname already has a locale - const pathnameHasLocale = SUPPORTED_LOCALES.some( + const matchedLocale = SUPPORTED_LOCALES.find( (locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}` ); - if (pathnameHasLocale) { - return NextResponse.next(); + if (matchedLocale) { + const requestHeaders = new Headers(request.headers); + requestHeaders.set('x-homaroma-locale', matchedLocale); + + return NextResponse.next({ + request: { + headers: requestHeaders, + }, + }); } // Determine the preferred locale diff --git a/packages/types/index.ts b/packages/types/index.ts index 6e8e5f8..2d0f13a 100644 --- a/packages/types/index.ts +++ b/packages/types/index.ts @@ -298,9 +298,9 @@ export const NewsletterSectionPayloadSchema = z.object({ title: LocalizedStringSchema, description: LocalizedStringSchema, buttonText: LocalizedStringSchema, - placeholderText: z.string().optional(), + placeholderText: LocalizedStringSchema.optional(), showPrivacyCheckbox: z.boolean(), - privacyText: z.string().optional(), + privacyText: LocalizedStringSchema.optional(), }); export type NewsletterSectionPayload = z.infer; @@ -309,7 +309,7 @@ export const EditorialTileSectionPayloadSchema = z.object({ description: LocalizedStringSchema, imageUrl: z.string().min(1), imagePosition: z.enum(['left', 'right']), - buttonText: z.string().optional(), + buttonText: LocalizedStringSchema.optional(), buttonLink: z.string().optional(), backgroundColor: z.string().optional(), textColor: z.string().optional(), @@ -323,7 +323,7 @@ export const EditorialCarouselSectionPayloadSchema = z.object({ title: LocalizedStringSchema, description: LocalizedStringSchema, imageUrl: z.string().min(1), - buttonText: z.string().optional(), + buttonText: LocalizedStringSchema.optional(), buttonLink: z.string().optional(), })).min(1) }); @@ -361,10 +361,11 @@ export const RichTextPayloadSchema = z.object({ export type RichTextPayload = z.infer; export const ScentNotesBreakdownPayloadSchema = z.object({ - title: z.string().optional(), - topNotes: z.array(z.string()), - middleNotes: z.array(z.string()), - baseNotes: z.array(z.string()), + title: LocalizedStringSchema.optional(), + description: LocalizedStringSchema.optional(), + topNotes: z.array(LocalizedStringSchema), + middleNotes: z.array(LocalizedStringSchema), + baseNotes: z.array(LocalizedStringSchema), }); export type ScentNotesBreakdownPayload = z.infer; @@ -375,8 +376,8 @@ export const QuoteCalloutPayloadSchema = z.object({ export type QuoteCalloutPayload = z.infer; export const ProductCarouselPayloadSchema = z.object({ - title: z.string().optional(), - description: z.string().optional(), + title: LocalizedStringSchema.optional(), + description: LocalizedStringSchema.optional(), collectionId: z.string().optional(), limit: z.number().int().positive().optional(), showPrice: z.boolean().optional(), @@ -434,12 +435,12 @@ export type ScentGuidePage = z.infer; export const AccordionSectionPayloadSchema = z.object({ categories: z.array( z.object({ - name: z.string().min(1, "Category name is required"), + name: LocalizedStringSchema, icon: z.string().optional(), questions: z.array( z.object({ - q: z.string().min(1, "Question is required"), - a: z.string().min(1, "Answer is required"), + q: LocalizedStringSchema, + a: LocalizedStringSchema, }) ), })