x
This commit is contained in:
1 parent
e405af357c
commit
e1cf9e62e7
51 files changed
+2598
-168
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import Image from 'next/image'
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ArrowRight, Home, Search, ShoppingBag } from 'lucide-react'
|
||||
import { getPathWithLocale, type Locale } from '@/lib/locales'
|
||||
|
||||
const copy = {
|
||||
@@ -13,7 +13,6 @@ const copy = {
|
||||
search: 'ค้นหาสินค้า',
|
||||
guide: 'คู่มือเลือกกลิ่น',
|
||||
contact: 'ติดต่อเรา',
|
||||
hint: '404',
|
||||
},
|
||||
en: {
|
||||
eyebrow: 'Page not found',
|
||||
@@ -24,7 +23,6 @@ const copy = {
|
||||
search: 'Search products',
|
||||
guide: 'Scent Guide',
|
||||
contact: 'Contact',
|
||||
hint: '404',
|
||||
},
|
||||
ja: {
|
||||
eyebrow: 'ページが見つかりません',
|
||||
@@ -35,7 +33,6 @@ const copy = {
|
||||
search: '商品を検索',
|
||||
guide: '香りガイド',
|
||||
contact: 'お問い合わせ',
|
||||
hint: '404',
|
||||
},
|
||||
zh: {
|
||||
eyebrow: '页面未找到',
|
||||
@@ -46,7 +43,6 @@ const copy = {
|
||||
search: '搜索商品',
|
||||
guide: '香氛指南',
|
||||
contact: '联系我们',
|
||||
hint: '404',
|
||||
},
|
||||
vi: {
|
||||
eyebrow: 'Không tìm thấy trang',
|
||||
@@ -57,7 +53,6 @@ const copy = {
|
||||
search: 'Tìm sản phẩm',
|
||||
guide: 'Hướng dẫn mùi hương',
|
||||
contact: 'Liên hệ',
|
||||
hint: '404',
|
||||
},
|
||||
ko: {
|
||||
eyebrow: '페이지를 찾을 수 없습니다',
|
||||
@@ -68,7 +63,6 @@ const copy = {
|
||||
search: '상품 검색',
|
||||
guide: '향 가이드',
|
||||
contact: '문의하기',
|
||||
hint: '404',
|
||||
},
|
||||
} as const
|
||||
|
||||
@@ -86,78 +80,105 @@ export default function LocalizedNotFound({ lang }: LocalizedNotFoundProps) {
|
||||
const text = getCopy(lang)
|
||||
|
||||
return (
|
||||
<section className="min-h-[70vh] bg-surface-page px-6 py-16 text-text-main 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="ui-panel mb-8 inline-flex items-center gap-3 px-4 py-2">
|
||||
<Image src="/favicon.svg" alt="" width={28} height={28} aria-hidden="true" />
|
||||
<span className="ui-eyebrow">{text.eyebrow}</span>
|
||||
</div>
|
||||
<div className="relative min-h-screen flex items-center justify-center px-6 overflow-hidden bg-text-main">
|
||||
|
||||
<p className="text-8xl font-black leading-none text-brand-primary/20 md:text-9xl">{text.hint}</p>
|
||||
<h1 className="mt-6 max-w-3xl text-4xl font-semibold leading-tight text-text-main md:text-6xl">
|
||||
{text.title}
|
||||
</h1>
|
||||
<p className="mt-6 max-w-2xl text-base leading-8 text-muted-text md:text-lg">
|
||||
{text.description}
|
||||
{/* Atmospheric glow background */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40rem] h-[40rem] bg-brand-primary/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-brand-warm/10 rounded-full blur-3xl" />
|
||||
<div className="absolute top-10 left-10 w-32 h-32 bg-brand-accent/5 rounded-full blur-2xl" />
|
||||
</div>
|
||||
|
||||
{/* Grain texture overlay */}
|
||||
<div
|
||||
className="absolute inset-0 opacity-[0.03] pointer-events-none"
|
||||
style={{
|
||||
backgroundImage: `url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)' opacity='1'/%3E%3C/svg%3E")`,
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-xl w-full">
|
||||
|
||||
{/* 404 Large number with layered effect */}
|
||||
<div className="relative select-none">
|
||||
<p
|
||||
className="text-[10rem] md:text-[14rem] font-black leading-none"
|
||||
style={{
|
||||
background: 'linear-gradient(to bottom, rgba(255,255,255,0.12), rgba(255,255,255,0.03))',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
backgroundClip: 'text',
|
||||
}}
|
||||
>
|
||||
404
|
||||
</p>
|
||||
|
||||
<div className="mt-10 flex flex-col gap-3 sm:flex-row">
|
||||
<Link
|
||||
href={getPathWithLocale('/', lang)}
|
||||
className="ui-button-primary px-6 py-3"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
{text.home}
|
||||
{/* Floating emoji center */}
|
||||
<div
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{ animation: 'float 4s ease-in-out infinite' }}
|
||||
>
|
||||
<span className="text-5xl md:text-6xl drop-shadow-2xl">🌿</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Eyebrow badge */}
|
||||
<div className="flex justify-center">
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-4 py-1.5 text-[10px] font-bold uppercase tracking-[0.25em] text-brand-primary backdrop-blur-sm">
|
||||
{text.eyebrow}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Copy */}
|
||||
<div className="space-y-4">
|
||||
<h1 className="font-serif text-2xl font-light leading-relaxed tracking-wide text-white/90 md:text-3xl">
|
||||
{text.title}
|
||||
</h1>
|
||||
<p className="text-sm font-light leading-relaxed text-white/50 mx-auto max-w-sm">
|
||||
{text.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Primary actions */}
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center pt-2">
|
||||
<Link
|
||||
href={getPathWithLocale('/', lang)}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full bg-brand-primary px-8 py-3.5 text-xs font-bold uppercase tracking-widest text-white transition-all duration-300 hover:bg-brand-primary-hover hover:shadow-[0_0_30px_rgba(255,107,107,0.4)] hover:-translate-y-0.5"
|
||||
>
|
||||
{text.home}
|
||||
</Link>
|
||||
<Link
|
||||
href={getPathWithLocale('/shop', lang)}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-full border border-white/15 bg-white/5 px-8 py-3.5 text-xs font-bold uppercase tracking-widest text-white/80 backdrop-blur-sm transition-all duration-300 hover:bg-white/10 hover:border-white/30 hover:-translate-y-0.5"
|
||||
>
|
||||
{text.shop}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Divider + quick links */}
|
||||
<div className="pt-4 border-t border-white/10">
|
||||
<div className="flex justify-center gap-6 text-xs text-white/30">
|
||||
<Link href={getPathWithLocale('/scent-guide', lang)} className="hover:text-white/70 transition-colors duration-300">
|
||||
{text.guide}
|
||||
</Link>
|
||||
<Link
|
||||
href={getPathWithLocale('/shop', lang)}
|
||||
className="ui-button-brand bg-surface-card text-text-main hover:bg-surface-page hover:text-brand-primary"
|
||||
>
|
||||
<ShoppingBag className="h-4 w-4" />
|
||||
{text.shop}
|
||||
<Link href={getPathWithLocale('/search', lang)} className="hover:text-white/70 transition-colors duration-300">
|
||||
{text.search}
|
||||
</Link>
|
||||
<Link href={getPathWithLocale('/contact', lang)} className="hover:text-white/70 transition-colors duration-300">
|
||||
{text.contact}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ui-panel border border-white/80 bg-surface-card/80 p-6 backdrop-blur md:p-8">
|
||||
<div className="rounded-[1.5rem] bg-surface-page p-6">
|
||||
<div className="flex items-center gap-3 border-b border-text-main/10 pb-5">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-surface-card text-brand-primary shadow-sm">
|
||||
<Search className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-bold uppercase tracking-[0.18em] text-muted-text">{text.hint}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-text-main">{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-surface-card px-5 py-4 text-sm font-semibold text-text-main transition hover:text-brand-primary"
|
||||
>
|
||||
<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-surface-card px-5 py-4 text-sm font-semibold text-text-main transition hover:text-brand-primary"
|
||||
>
|
||||
<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-surface-card px-5 py-4 text-sm font-semibold text-text-main transition hover:text-brand-primary"
|
||||
>
|
||||
<span>{text.contact}</span>
|
||||
<ArrowRight className="h-4 w-4 transition group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Float keyframe via inline style */}
|
||||
<style>{`
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-14px); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
import Image from 'next/image'
|
||||
import Link from '../LocalizedLink'
|
||||
import { Clock, Mail, MapPin, MessageCircle, Send, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useState, useRef } from 'react'
|
||||
import { Clock, Mail, MapPin, MessageCircle, Send } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { Category, PageSection, Product } from '@homaroma/types'
|
||||
import { getCategoryImage } from '@homaroma/types'
|
||||
import ProductCard from '../ProductCard'
|
||||
@@ -11,8 +11,10 @@ import { localizedFromRecord, resolveCategoryName, resolveLocalized, toRecord }
|
||||
import { useLang } from '../../hooks/useLang'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Swiper, SwiperSlide } from 'swiper/react'
|
||||
import { Autoplay } from 'swiper/modules'
|
||||
import { Autoplay, Pagination, Navigation } from 'swiper/modules'
|
||||
import 'swiper/css'
|
||||
import 'swiper/css/pagination'
|
||||
import 'swiper/css/navigation'
|
||||
|
||||
type DynamicPageRendererProps = {
|
||||
sections: PageSection[]
|
||||
@@ -402,33 +404,38 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
|
||||
const bgClass = backgroundColor ? `bg-pop-${backgroundColor}` : 'bg-surface-page'
|
||||
const textClass = textColor ? `text-pop-${textColor}` : 'text-text-main'
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollBy = (offset: number) => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollBy({ left: offset, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
const isSplitScreen = payload.layout === 'split_screen'
|
||||
const imagePosition = typeof payload.imagePosition === 'string' ? payload.imagePosition : 'left'
|
||||
|
||||
if (!slides.length) return null
|
||||
|
||||
return (
|
||||
<section className={`px-6 py-16 md:py-24 ${bgClass} ${textClass}`}>
|
||||
<div className="mx-auto max-w-7xl space-y-12">
|
||||
<section className={`overflow-hidden py-16 md:py-24 ${bgClass} ${textClass}`}>
|
||||
<div className={isSplitScreen ? 'w-full space-y-12' : 'mx-auto max-w-7xl px-6 space-y-12'}>
|
||||
{title && (
|
||||
<div className="mx-auto max-w-3xl space-y-4 text-center">
|
||||
<h2 className={`text-4xl font-semibold md:text-5xl ${textClass}`}>{renderAccentTitle(title)}</h2>
|
||||
<div className="mx-auto h-px w-16 bg-brand-accent" />
|
||||
<div className={`space-y-4 text-center ${isSplitScreen ? 'px-4 md:px-8' : ''}`}>
|
||||
<h2 className={`font-serif text-3xl font-light tracking-wide md:text-5xl ${textClass}`}>
|
||||
{renderAccentTitle(title)}
|
||||
</h2>
|
||||
<div className="mx-auto h-px w-16 bg-brand-primary" />
|
||||
</div>
|
||||
)}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => scrollBy(-400)}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -left-5"
|
||||
aria-label="Scroll left"
|
||||
|
||||
<div className="relative select-none pb-12">
|
||||
<Swiper
|
||||
modules={[Autoplay, Pagination, Navigation]}
|
||||
spaceBetween={isSplitScreen ? 0 : 30}
|
||||
slidesPerView={1}
|
||||
breakpoints={isSplitScreen ? undefined : {
|
||||
640: { slidesPerView: 2 },
|
||||
1024: { slidesPerView: 3 }
|
||||
}}
|
||||
pagination={{ clickable: true, bulletActiveClass: 'swiper-pagination-bullet-active !bg-brand-primary' }}
|
||||
navigation={true}
|
||||
autoplay={{ delay: 5000, disableOnInteraction: false }}
|
||||
grabCursor={true}
|
||||
className="w-full"
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6 text-brand-primary" />
|
||||
</button>
|
||||
<div ref={scrollContainerRef} className="flex overflow-x-auto snap-x snap-mandatory hide-scrollbar gap-6 pb-8">
|
||||
{slides.map((slide, index) => {
|
||||
const slideTitle = localizedFromRecord(slide, 'title', resolvedLang)
|
||||
const description = localizedFromRecord(slide, 'description', resolvedLang)
|
||||
@@ -438,43 +445,74 @@ function EditorialCarouselSection({ payload, lang }: { payload: Record<string, u
|
||||
const imageUrl = typeof slide.imageUrl === 'string' ? slide.imageUrl : ''
|
||||
|
||||
return (
|
||||
<article key={index} className="ui-card overflow-hidden group w-[85vw] md:w-[350px] flex-none snap-start">
|
||||
{imageUrl ? (
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={slideTitle || 'Editorial slide'}
|
||||
fill
|
||||
unoptimized
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="space-y-4 p-6 flex flex-col flex-1 justify-between">
|
||||
<div>
|
||||
{category && <span className="text-[10px] font-bold uppercase tracking-[0.24em] text-brand-primary block">{category}</span>}
|
||||
{slideTitle && <h3 className="text-xl font-bold text-text-main mt-6 group-hover:text-brand-primary transition-colors">{renderAccentTitle(slideTitle)}</h3>}
|
||||
{description && <p className="text-base text-muted-text mt-3 whitespace-pre-wrap">{description}</p>}
|
||||
</div>
|
||||
{buttonText && buttonLink && (
|
||||
<div className="pt-2">
|
||||
<Link href={buttonLink} className="ui-section-dark-link inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest transition-colors group/link">
|
||||
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
<SwiperSlide key={index} className="h-auto flex flex-col">
|
||||
{isSplitScreen ? (
|
||||
<div className={`group grid grid-cols-1 bg-surface-card overflow-hidden transition-all duration-300 ${
|
||||
imagePosition === 'left' ? 'md:grid-cols-[65%_35%]' : 'md:grid-cols-[35%_65%]'
|
||||
}`}>
|
||||
{imageUrl ? (
|
||||
<div className={`relative aspect-[4/3] md:aspect-auto overflow-hidden h-64 md:h-full min-h-[300px] md:min-h-[500px] lg:min-h-[600px] ${
|
||||
imagePosition === 'left' ? 'order-1 md:order-1' : 'order-1 md:order-2'
|
||||
}`}>
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={slideTitle || 'Editorial slide'}
|
||||
fill
|
||||
unoptimized
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className={`space-y-6 p-6 md:p-16 lg:p-24 flex flex-col justify-center ${
|
||||
imagePosition === 'left' ? 'order-2 md:order-2' : 'order-2 md:order-1'
|
||||
}`}>
|
||||
<div className="space-y-3">
|
||||
{category && <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-brand-primary">{category}</span>}
|
||||
{slideTitle && <h3 className="font-serif text-xl font-light tracking-wide text-text-main">{renderAccentTitle(slideTitle)}</h3>}
|
||||
{description && <p className="line-clamp-6 text-xs font-light leading-relaxed text-muted-text md:text-sm">{description}</p>}
|
||||
</div>
|
||||
{buttonText && buttonLink && (
|
||||
<div className="pt-2">
|
||||
<Link href={buttonLink} className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-brand-primary hover:text-text-main transition-colors group/link">
|
||||
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
) : (
|
||||
<div className="ui-card overflow-hidden group flex flex-col h-full transition-all duration-300 hover:border-brand-primary/30">
|
||||
{imageUrl ? (
|
||||
<div className="relative aspect-[4/3] overflow-hidden">
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt={slideTitle || 'Editorial slide'}
|
||||
fill
|
||||
unoptimized
|
||||
className="h-full w-full object-cover transition duration-700 group-hover:scale-105"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="p-6 flex flex-col flex-1 justify-between space-y-6">
|
||||
<div className="space-y-3">
|
||||
{category && <span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-brand-primary">{category}</span>}
|
||||
{slideTitle && <h3 className="font-serif text-xl font-light tracking-wide text-text-main">{renderAccentTitle(slideTitle)}</h3>}
|
||||
{description && <p className="line-clamp-3 text-xs font-light leading-relaxed text-muted-text md:text-sm">{description}</p>}
|
||||
</div>
|
||||
{buttonText && buttonLink && (
|
||||
<div className="pt-2">
|
||||
<Link href={buttonLink} className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-brand-primary hover:text-text-main transition-colors group/link">
|
||||
{buttonText} <span className="group-hover/link:translate-x-1 transition-transform">→</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SwiperSlide>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => scrollBy(400)}
|
||||
className="hidden md:flex bg-surface-card rounded-full shadow-card hover:text-brand-primary h-10 w-10 items-center justify-center absolute z-10 top-1/2 -translate-y-1/2 -right-5"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="h-6 w-6 text-brand-primary" />
|
||||
</button>
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in new issue
Block a user