This commit is contained in:
Your Name committed 2026-06-20 20:33:34 -04:00
1 parent 78f2cb62aa
commit aefa13e363
6 files changed
+209 -17

No files matched your search

@@ -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()
}
+11
View File
@@ -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 <LocalizedNotFound lang={lang} />
}
@@ -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 (
<section className="min-h-[70vh] bg-pop-bg px-6 py-16 text-pop-black md:py-24">
<div className="mx-auto grid max-w-6xl items-center gap-12 lg:grid-cols-[1.05fr_0.95fr]">
<div>
<div className="mb-8 inline-flex items-center gap-3 rounded-full border border-pop-pink/20 bg-white px-4 py-2 shadow-brutal">
<Image src="/favicon.svg" alt="" width={28} height={28} aria-hidden="true" />
<span className="text-xs font-bold uppercase tracking-[0.2em] text-pop-pink">{text.eyebrow}</span>
</div>
<p className="text-8xl font-black leading-none text-pop-pink/20 md:text-9xl">{text.hint}</p>
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-pop-black md:text-6xl">
{text.title}
</h1>
<p className="mt-6 max-w-2xl text-base leading-8 text-gray-600 md:text-lg">
{text.description}
</p>
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
<Link
href={getPathWithLocale('/', lang)}
className="inline-flex items-center justify-center gap-2 rounded-full bg-pop-black px-6 py-3 text-sm font-bold text-white transition hover:bg-pop-pink"
>
<Home className="h-4 w-4" />
{text.home}
</Link>
<Link
href={getPathWithLocale('/shop', lang)}
className="inline-flex items-center justify-center gap-2 rounded-full border border-pop-pink/40 bg-white px-6 py-3 text-sm font-bold text-pop-black transition hover:border-pop-pink hover:text-pop-pink"
>
<ShoppingBag className="h-4 w-4" />
{text.shop}
</Link>
</div>
</div>
<div className="rounded-[2rem] border border-white bg-white/80 p-6 shadow-brutal backdrop-blur md:p-8">
<div className="rounded-[1.5rem] bg-pop-bg p-6">
<div className="flex items-center gap-3 border-b border-pop-black/10 pb-5">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white text-pop-pink shadow-sm">
<Search className="h-5 w-5" />
</div>
<div>
<p className="text-xs font-bold uppercase tracking-[0.18em] text-gray-500">{text.hint}</p>
<p className="mt-1 text-sm font-semibold text-pop-black">{text.search}</p>
</div>
</div>
<div className="mt-6 grid gap-3">
<Link
href={getPathWithLocale('/search', lang)}
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink"
>
<span>{text.search}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
<Link
href={getPathWithLocale('/scent-guide', lang)}
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink"
>
<span>{text.guide}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
<Link
href={getPathWithLocale('/contact', lang)}
className="group flex items-center justify-between rounded-2xl bg-white px-5 py-4 text-sm font-semibold text-pop-black transition hover:text-pop-pink"
>
<span>{text.contact}</span>
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
</Link>
</div>
</div>
</div>
</div>
</section>
)
}
@@ -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)
}
+10 -3
View File
@@ -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
+14 -13
View File
@@ -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<typeof NewsletterSectionPayloadSchema>;
@@ -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<typeof RichTextPayloadSchema>;
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<typeof ScentNotesBreakdownPayloadSchema>;
@@ -375,8 +376,8 @@ export const QuoteCalloutPayloadSchema = z.object({
export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
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<typeof ScentGuidePageSchema>;
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,
})
),
})