x
This commit is contained in:
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()
|
||||||
|
}
|
||||||
@@ -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) {
|
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)
|
return localizedValue(value, lang)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,19 @@ export function proxy(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if the pathname already has a locale
|
// Check if the pathname already has a locale
|
||||||
const pathnameHasLocale = SUPPORTED_LOCALES.some(
|
const matchedLocale = SUPPORTED_LOCALES.find(
|
||||||
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pathnameHasLocale) {
|
if (matchedLocale) {
|
||||||
return NextResponse.next();
|
const requestHeaders = new Headers(request.headers);
|
||||||
|
requestHeaders.set('x-homaroma-locale', matchedLocale);
|
||||||
|
|
||||||
|
return NextResponse.next({
|
||||||
|
request: {
|
||||||
|
headers: requestHeaders,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine the preferred locale
|
// Determine the preferred locale
|
||||||
|
|||||||
+14
-13
@@ -298,9 +298,9 @@ export const NewsletterSectionPayloadSchema = z.object({
|
|||||||
title: LocalizedStringSchema,
|
title: LocalizedStringSchema,
|
||||||
description: LocalizedStringSchema,
|
description: LocalizedStringSchema,
|
||||||
buttonText: LocalizedStringSchema,
|
buttonText: LocalizedStringSchema,
|
||||||
placeholderText: z.string().optional(),
|
placeholderText: LocalizedStringSchema.optional(),
|
||||||
showPrivacyCheckbox: z.boolean(),
|
showPrivacyCheckbox: z.boolean(),
|
||||||
privacyText: z.string().optional(),
|
privacyText: LocalizedStringSchema.optional(),
|
||||||
});
|
});
|
||||||
export type NewsletterSectionPayload = z.infer<typeof NewsletterSectionPayloadSchema>;
|
export type NewsletterSectionPayload = z.infer<typeof NewsletterSectionPayloadSchema>;
|
||||||
|
|
||||||
@@ -309,7 +309,7 @@ export const EditorialTileSectionPayloadSchema = z.object({
|
|||||||
description: LocalizedStringSchema,
|
description: LocalizedStringSchema,
|
||||||
imageUrl: z.string().min(1),
|
imageUrl: z.string().min(1),
|
||||||
imagePosition: z.enum(['left', 'right']),
|
imagePosition: z.enum(['left', 'right']),
|
||||||
buttonText: z.string().optional(),
|
buttonText: LocalizedStringSchema.optional(),
|
||||||
buttonLink: z.string().optional(),
|
buttonLink: z.string().optional(),
|
||||||
backgroundColor: z.string().optional(),
|
backgroundColor: z.string().optional(),
|
||||||
textColor: z.string().optional(),
|
textColor: z.string().optional(),
|
||||||
@@ -323,7 +323,7 @@ export const EditorialCarouselSectionPayloadSchema = z.object({
|
|||||||
title: LocalizedStringSchema,
|
title: LocalizedStringSchema,
|
||||||
description: LocalizedStringSchema,
|
description: LocalizedStringSchema,
|
||||||
imageUrl: z.string().min(1),
|
imageUrl: z.string().min(1),
|
||||||
buttonText: z.string().optional(),
|
buttonText: LocalizedStringSchema.optional(),
|
||||||
buttonLink: z.string().optional(),
|
buttonLink: z.string().optional(),
|
||||||
})).min(1)
|
})).min(1)
|
||||||
});
|
});
|
||||||
@@ -361,10 +361,11 @@ export const RichTextPayloadSchema = z.object({
|
|||||||
export type RichTextPayload = z.infer<typeof RichTextPayloadSchema>;
|
export type RichTextPayload = z.infer<typeof RichTextPayloadSchema>;
|
||||||
|
|
||||||
export const ScentNotesBreakdownPayloadSchema = z.object({
|
export const ScentNotesBreakdownPayloadSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: LocalizedStringSchema.optional(),
|
||||||
topNotes: z.array(z.string()),
|
description: LocalizedStringSchema.optional(),
|
||||||
middleNotes: z.array(z.string()),
|
topNotes: z.array(LocalizedStringSchema),
|
||||||
baseNotes: z.array(z.string()),
|
middleNotes: z.array(LocalizedStringSchema),
|
||||||
|
baseNotes: z.array(LocalizedStringSchema),
|
||||||
});
|
});
|
||||||
export type ScentNotesBreakdownPayload = z.infer<typeof ScentNotesBreakdownPayloadSchema>;
|
export type ScentNotesBreakdownPayload = z.infer<typeof ScentNotesBreakdownPayloadSchema>;
|
||||||
|
|
||||||
@@ -375,8 +376,8 @@ export const QuoteCalloutPayloadSchema = z.object({
|
|||||||
export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
|
export type QuoteCalloutPayload = z.infer<typeof QuoteCalloutPayloadSchema>;
|
||||||
|
|
||||||
export const ProductCarouselPayloadSchema = z.object({
|
export const ProductCarouselPayloadSchema = z.object({
|
||||||
title: z.string().optional(),
|
title: LocalizedStringSchema.optional(),
|
||||||
description: z.string().optional(),
|
description: LocalizedStringSchema.optional(),
|
||||||
collectionId: z.string().optional(),
|
collectionId: z.string().optional(),
|
||||||
limit: z.number().int().positive().optional(),
|
limit: z.number().int().positive().optional(),
|
||||||
showPrice: z.boolean().optional(),
|
showPrice: z.boolean().optional(),
|
||||||
@@ -434,12 +435,12 @@ export type ScentGuidePage = z.infer<typeof ScentGuidePageSchema>;
|
|||||||
export const AccordionSectionPayloadSchema = z.object({
|
export const AccordionSectionPayloadSchema = z.object({
|
||||||
categories: z.array(
|
categories: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string().min(1, "Category name is required"),
|
name: LocalizedStringSchema,
|
||||||
icon: z.string().optional(),
|
icon: z.string().optional(),
|
||||||
questions: z.array(
|
questions: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
q: z.string().min(1, "Question is required"),
|
q: LocalizedStringSchema,
|
||||||
a: z.string().min(1, "Answer is required"),
|
a: LocalizedStringSchema,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in new issue
Block a user