This commit is contained in:
Your Name committed 2026-06-28 07:58:15 -04:00
1 parent e405af357c
commit e1cf9e62e7
51 files changed
+2598 -168

No files matched your search

@@ -85,6 +85,38 @@ export default function EditorialCarouselForm({
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Layout</label>
<select
value={editingSection.payload?.layout || 'standard'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), layout: e.target.value }
})}
className="admin-input w-full"
>
<option value="standard">Standard Carousel</option>
<option value="split_screen">Split Screen</option>
</select>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Image Position</label>
<select
value={editingSection.payload?.imagePosition || 'left'}
disabled={editingSection.payload?.layout !== 'split_screen'}
onChange={(e) => setEditingSection({
...editingSection,
payload: { ...(editingSection.payload || {}), imagePosition: e.target.value }
})}
className="admin-input w-full disabled:opacity-50"
>
<option value="left">Left</option>
<option value="right">Right</option>
</select>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between border-b border-white/5 pb-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-400">Editorial Slides</label>
+82 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'
import { API_BASE, type StoreSettings } from '@homaroma/types'
import { Save, Loader2, Landmark, Truck } from 'lucide-react'
import { Save, Loader2, Landmark, Truck, DatabaseZap, CheckCircle2, AlertCircle } from 'lucide-react'
const defaultThemeTokens = {
brandPrimary: '#FF6B6B',
@@ -60,6 +60,8 @@ function resolveFontPreset(themeTokens: Record<string, string>) {
export default function SystemSettingsPage() {
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [seeding, setSeeding] = useState(false)
const [seedResult, setSeedResult] = useState<{ success: boolean; message: string; results?: { slug: string; status: string }[] } | null>(null)
// Form State
const [brandName, setBrandName] = useState('')
@@ -126,6 +128,24 @@ export default function SystemSettingsPage() {
.finally(() => setLoading(false))
}, [])
const handleSeedPages = async () => {
setSeeding(true)
setSeedResult(null)
try {
const res = await fetch(`${API_BASE}/settings/seed-pages`, { method: 'POST' })
const data = await res.json()
if (res.ok && data.success) {
setSeedResult({ success: true, message: data.message, results: data.results })
} else {
setSeedResult({ success: false, message: data.error || 'Seed failed' })
}
} catch (err) {
setSeedResult({ success: false, message: 'Network error — could not reach backend' })
} finally {
setSeeding(false)
}
}
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
setSaving(true)
@@ -536,6 +556,67 @@ export default function SystemSettingsPage() {
Save Settings
</button>
</form>
{/* System Maintenance */}
<div className="admin-card p-8 space-y-6">
<div className="flex items-center gap-3 border-b border-white/10 pb-4">
<div className="p-2 bg-amber-500/10 rounded-lg">
<DatabaseZap className="w-5 h-5 text-amber-400" />
</div>
<div>
<h2 className="text-lg font-bold uppercase tracking-widest text-white">System Maintenance</h2>
<p className="text-xs text-slate-400 mt-0.5">Utilities for initializing or repairing system data</p>
</div>
</div>
<div className="space-y-4">
<div className="rounded-xl border border-white/10 bg-white/5 p-6 space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<p className="text-sm font-bold text-white">Seed Required Pages</p>
<p className="text-xs text-slate-400 leading-relaxed max-w-lg">
Creates all 15 required CMS layout pages (home, shop-layout, checkout-layout, profile-layout, etc.) if they do not already exist in the database. Safe to run multiple times — existing pages are not modified.
</p>
</div>
<button
type="button"
onClick={handleSeedPages}
disabled={seeding}
className="shrink-0 flex items-center gap-2 rounded-lg bg-amber-500/10 border border-amber-500/30 px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-amber-400 transition hover:bg-amber-500/20 disabled:opacity-50 disabled:cursor-not-allowed"
>
{seeding ? <Loader2 className="w-4 h-4 animate-spin" /> : <DatabaseZap className="w-4 h-4" />}
{seeding ? 'Running...' : 'Run Seed'}
</button>
</div>
{seedResult && (
<div className={`rounded-lg p-4 text-sm space-y-3 ${
seedResult.success ? 'bg-emerald-500/10 border border-emerald-500/20' : 'bg-red-500/10 border border-red-500/20'
}`}>
<div className="flex items-center gap-2">
{seedResult.success
? <CheckCircle2 className="w-4 h-4 text-emerald-400 shrink-0" />
: <AlertCircle className="w-4 h-4 text-red-400 shrink-0" />}
<span className={seedResult.success ? 'text-emerald-300' : 'text-red-300'}>
{seedResult.message}
</span>
</div>
{seedResult.results && (
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
{seedResult.results.map(r => (
<div key={r.slug} className="flex items-center gap-1.5 text-xs">
<span className={r.status === 'created' ? 'text-emerald-400' : 'text-slate-500'}>●</span>
<span className={r.status === 'created' ? 'text-slate-200' : 'text-slate-500'}>{r.slug}</span>
{r.status === 'created' && <span className="text-emerald-400 font-bold">NEW</span>}
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+91
View File
@@ -699,6 +699,97 @@ async function main() {
});
console.log("✅ Seeded default Category, Product Detail, FAQ, Wishlist, Search, Coupons, Contact, Order Success, and Track Order layouts successfully!");
// 14. Create Checkout Page Layout
const checkoutPage = await prisma.page.upsert({
where: { slug: "checkout-layout" },
update: {},
create: {
slug: "checkout-layout",
title: "Checkout Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${checkoutPage.title} (slug: ${checkoutPage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: checkoutPage.id } });
await prisma.pageSection.create({
data: {
pageId: checkoutPage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Secure checkout powered by HomAroma — all payments are encrypted and protected", "Free shipping on orders over ฿1,500"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 4000
}
}
});
await prisma.pageSection.create({
data: {
pageId: checkoutPage.id,
type: "EDITORIAL_TILE",
order: 2,
isActive: true,
payload: {
title: "Crafted for Your [Sanctuary]",
description: "Every HomAroma order is hand-packed with care using eco-conscious materials, so your botanicals arrive in perfect condition.",
imageUrl: "/images/oil_ozone.png",
imagePosition: "right",
buttonText: "Our Sustainability Promise",
buttonLink: "/about",
backgroundColor: "#FDFDF9",
textColor: "#1F3D2D"
}
}
});
// 15. Create Profile Page Layout
const profilePage = await prisma.page.upsert({
where: { slug: "profile-layout" },
update: {},
create: {
slug: "profile-layout",
title: "Profile Page Layout",
isPublished: true,
}
});
console.log(`✅ Created page: ${profilePage.title} (slug: ${profilePage.slug})`);
await prisma.pageSection.deleteMany({ where: { pageId: profilePage.id } });
await prisma.pageSection.create({
data: {
pageId: profilePage.id,
type: "ANNOUNCEMENT_BAR",
order: 1,
isActive: true,
payload: {
slides: ["Welcome back to HomAroma — manage your orders and wishlist from your profile", "Earn points with every purchase"],
backgroundColor: "#1F3D2D",
textColor: "#FDFDF9",
speed: 4000
}
}
});
await prisma.pageSection.create({
data: {
pageId: profilePage.id,
type: "NEWSLETTER",
order: 2,
isActive: true,
payload: {
title: "Exclusive Member Benefits",
description: "Subscribe to receive early access to new collections, member-only discounts, and scent guides curated just for you.",
buttonText: "Subscribe",
placeholderText: "Enter your email address",
showPrivacyCheckbox: true,
privacyText: "I agree to the privacy policy."
}
}
});
console.log("✅ Seeded checkout-layout and profile-layout successfully!");
console.log("🌱 All 15 required pages seeded successfully!");
}
main()
Binary file not shown.

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 592 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 694 KiB

+51
View File
@@ -115,4 +115,55 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
set.status = 500;
return { error: "Failed to update settings translation" };
}
})
.post("/seed-pages", async ({ set }) => {
try {
const REQUIRED_PAGES = [
{ slug: "home", title: "Home Page" },
{ slug: "shop-layout", title: "Shop Page Layout" },
{ slug: "scent-guide-layout", title: "Scent Guide Layout" },
{ slug: "category-layout", title: "Category Page Layout" },
{ slug: "product-detail-layout", title: "Product Detail Layout" },
{ slug: "faq-layout", title: "FAQ Page Layout" },
{ slug: "wishlist-layout", title: "Wishlist Page Layout" },
{ slug: "search-layout", title: "Search Page Layout" },
{ slug: "coupons-layout", title: "Coupons Page Layout" },
{ slug: "contact-layout", title: "Contact Page Layout" },
{ slug: "order-success-layout", title: "Order Success Page Layout" },
{ slug: "track-order-layout", title: "Track Order Page Layout" },
{ slug: "checkout-layout", title: "Checkout Page Layout" },
{ slug: "profile-layout", title: "Profile Page Layout" },
];
const results: { slug: string; status: "created" | "exists" }[] = [];
for (const page of REQUIRED_PAGES) {
const existing = await prisma.page.findUnique({ where: { slug: page.slug } });
if (!existing) {
await prisma.page.create({
data: {
slug: page.slug,
title: page.title,
isPublished: true,
}
});
results.push({ slug: page.slug, status: "created" });
} else {
results.push({ slug: page.slug, status: "exists" });
}
}
const created = results.filter(r => r.status === "created").length;
const existed = results.filter(r => r.status === "exists").length;
return {
success: true,
message: `Seed complete: ${created} pages created, ${existed} pages already existed.`,
results
};
} catch (err) {
console.error("[Settings] Seed Pages Error:", err);
set.status = 500;
return { error: "Failed to seed pages" };
}
});
+3 -1
View File
@@ -264,13 +264,15 @@ const SECTION_TOOL_CONFIGS = [
{
name: "update_editorial_carousel",
sectionType: "EDITORIAL_CAROUSEL",
description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "description": ${localizedStringExample} }`,
description: `Update an EDITORIAL_CAROUSEL section. This tool performs a partial update/merge (PATCH). Omitted fields are preserved. The 'sectionId' argument correlates to the 'id' field in the fetched payload. title, description, layout, imagePosition, and slides are supported. Example payload: { "sectionId": 12, "title": ${localizedStringExample}, "layout": "split_screen", "imagePosition": "left" }`,
properties: {
sectionId: { type: "number", description: "The numeric ID of the section to update" },
title: { ...localizedStringSchema, description: `Localized section title, for example ${localizedStringExample}` },
description: { ...localizedStringSchema, description: `Localized section description, for example ${localizedStringExample}` },
backgroundColor: { type: "string", description: "Plain string background color in hex or rgba" },
textColor: { type: "string", description: "Plain string text color in hex or rgba" },
layout: { type: "string", enum: ["standard", "split_screen"], description: "Layout style of the carousel (standard or split_screen)" },
imagePosition: { type: "string", enum: ["left", "right"], description: "Position of the image relative to text when layout is split_screen (left or right)" },
slides: {
type: "array",
items: {
@@ -73,10 +73,13 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
const bgClass = payload.backgroundColor && bgMap[payload.backgroundColor] ? bgMap[payload.backgroundColor] : 'bg-pop-bg';
const textClass = payload.textColor && textMap[payload.textColor] ? textMap[payload.textColor] : 'text-pop-black';
const isSplitScreen = payload.layout === 'split_screen';
const imagePosition = payload.imagePosition || 'left';
return (
<section className={`overflow-hidden py-16 md:py-24 ${bgClass} ${textClass}`}>
<div className="ui-section-inner space-y-12">
<div className="space-y-3 text-center">
<div className={isSplitScreen ? "w-full space-y-12" : "ui-section-inner space-y-12"}>
<div className={`space-y-3 text-center ${isSplitScreen ? 'px-4 md:px-8' : ''}`}>
<h2 className="font-serif text-3xl font-light tracking-wide text-pop-pink md:text-5xl">
{renderTitle(title) || title}
</h2>
@@ -86,9 +89,9 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
<div className="relative select-none pb-12">
<Swiper
modules={[Autoplay, Pagination, Navigation]}
spaceBetween={30}
spaceBetween={isSplitScreen ? 0 : 30}
slidesPerView={1}
breakpoints={{
breakpoints={isSplitScreen ? undefined : {
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 }
}}
@@ -99,44 +102,91 @@ export default function DynamicEditorialCarousel({ payload }: DynamicEditorialCa
>
{slides.map((slide, idx) => (
<SwiperSlide key={idx} className="h-auto flex flex-col">
<div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-pop-pink/30">
<div className="aspect-[4/3] overflow-hidden relative">
<img
src={slide.imageUrl}
alt={slide.title}
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
<div className="space-y-3">
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
{slide.category}
</span>
<h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
{renderTitle(slide.title) || slide.title}
</h3>
<p className="line-clamp-3 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
{slide.description}
</p>
{isSplitScreen ? (
<div className={`group grid grid-cols-1 bg-night-900 overflow-hidden transition-all duration-300 ${
imagePosition === 'left' ? 'md:grid-cols-[65%_35%]' : 'md:grid-cols-[35%_65%]'
}`}>
<div className={`overflow-hidden relative ${
imagePosition === 'left' ? 'order-1 md:order-1' : 'order-1 md:order-2'
} aspect-[4/3] md:aspect-auto h-64 md:h-full min-h-[300px] md:min-h-[500px] lg:min-h-[600px]`}>
<img
src={slide.imageUrl}
alt={slide.title}
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
{slide.buttonText && slide.buttonLink && (
<div className="pt-2">
<Link
to={slide.buttonLink}
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
>
{slide.buttonText}
<span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
<div className={`p-6 md:p-16 lg:p-24 flex flex-col justify-center space-y-8 ${
imagePosition === 'left' ? 'order-2 md:order-2' : 'order-2 md:order-1'
}`}>
<div className="space-y-3">
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
{slide.category}
</span>
<h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
{renderTitle(slide.title) || slide.title}
</h3>
<p className="line-clamp-6 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
{slide.description}
</p>
</div>
)}
{slide.buttonText && slide.buttonLink && (
<div className="pt-2">
<Link
to={slide.buttonLink}
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
>
{slide.buttonText}
<span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div>
)}
</div>
</div>
</div>
) : (
<div className="ui-dark-card group flex h-full flex-col transition-all duration-300 hover:border-pop-pink/30">
<div className="aspect-[4/3] overflow-hidden relative">
<img
src={slide.imageUrl}
alt={slide.title}
className="w-full h-full object-cover transition-transform duration-700 ease-out group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
</div>
<div className="p-6 md:p-8 flex flex-col flex-1 justify-between space-y-6">
<div className="space-y-3">
<span className="block text-[10px] font-bold uppercase tracking-[0.2em] text-pop-pink">
{slide.category}
</span>
<h3 className="font-serif text-xl font-light tracking-wide text-pop-black">
{renderTitle(slide.title) || slide.title}
</h3>
<p className="line-clamp-3 text-xs font-light leading-relaxed text-pop-black/70 md:text-sm">
{slide.description}
</p>
</div>
{slide.buttonText && slide.buttonLink && (
<div className="pt-2">
<Link
to={slide.buttonLink}
className="inline-flex items-center gap-2 text-xs font-bold uppercase tracking-widest text-pop-pink hover:text-pop-black transition-colors group/link"
>
{slide.buttonText}
<span className="group-hover/link:translate-x-1 transition-transform">&rarr;</span>
</Link>
</div>
)}
</div>
</div>
)}
</SwiperSlide>
))}
</Swiper>
+10
View File
@@ -26,6 +26,9 @@
--color-pop-bg: var(--color-surface-page);
--color-pop-black: var(--color-text-main);
/* Serif font — mirrors apps/spa font-serif token */
--font-serif: var(--font-serif-locale, ui-serif), Georgia, Cambria, "Times New Roman", Times, serif;
--shadow-card: 0 10px 25px rgba(0,0,0,0.05);
--shadow-card-hover: 0 20px 40px rgba(0,0,0,0.1);
--shadow-brutal: var(--shadow-card);
@@ -40,36 +43,43 @@
}
}
@layer base {
:root,
html {
--font-body-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: "Sarabun", "Noto Sans Thai", ui-sans-serif, system-ui, sans-serif;
--font-serif-locale: ui-serif;
}
html[lang="en"] {
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale);
--font-serif-locale: ui-serif;
}
html[lang="ja"] {
--font-body-locale: "Hiragino Sans", "Yu Gothic", "Noto Sans JP", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif JP", ui-serif;
}
html[lang="zh"] {
--font-body-locale: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif SC", ui-serif;
}
html[lang="ko"] {
--font-body-locale: "Apple SD Gothic Neo", "Malgun Gothic", "Noto Sans KR", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale);
--font-serif-locale: "Noto Serif KR", ui-serif;
}
html[lang="vi"] {
--font-body-locale: "Inter", "Segoe UI", ui-sans-serif, system-ui, sans-serif;
--font-display-locale: var(--font-body-locale);
--font-serif-locale: ui-serif;
}
html {
@@ -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">&rarr;</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">&rarr;</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">&rarr;</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>