Rename ssr to website, website to spa, add spa nginx config
This commit is contained in:
1 parent
fc6bf0218e
commit
8514342c2f
103 files changed
+801
-768
No files matched your search
@@ -1,184 +0,0 @@
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useEffect } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import ScrollToTop from './components/ScrollToTop'
|
||||
import { initLiff } from './lib/liff'
|
||||
import HomePage from './pages/HomePage'
|
||||
import ShopPage from './pages/ShopPage'
|
||||
import CategoryPage from './pages/CategoryPage'
|
||||
import ProductDetailPage from './pages/ProductDetailPage'
|
||||
import SearchPage from './pages/SearchPage'
|
||||
import FaqPage from './pages/FaqPage'
|
||||
import ScentGuidePage from './pages/ScentGuidePage'
|
||||
import ArticleReaderPage from './pages/ArticleReaderPage'
|
||||
import ContactPage from './pages/ContactPage'
|
||||
import AboutPage from './pages/AboutPage'
|
||||
import NotFoundPage from './pages/NotFoundPage'
|
||||
import CheckoutPage from './pages/CheckoutPage'
|
||||
import CouponsPage from './pages/CouponsPage'
|
||||
import OrderSuccessPage from './pages/OrderSuccessPage'
|
||||
import TrackOrderPage from './pages/TrackOrderPage'
|
||||
import CartDrawer from './components/CartDrawer'
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
const liffId = import.meta.env.VITE_LIFF_ID;
|
||||
if (liffId) {
|
||||
initLiff(liffId);
|
||||
} else {
|
||||
console.warn("VITE_LIFF_ID is not defined in environment variables. LINE Login won't work.");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ScrollToTop />
|
||||
<CartDrawer />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/shop" element={<ShopPage />} />
|
||||
<Route path="/category/:id" element={<CategoryPage />} />
|
||||
<Route path="/products/:id" element={<ProductDetailPage />} />
|
||||
<Route path="/coupons" element={<CouponsPage />} />
|
||||
<Route path="/checkout" element={<CheckoutPage />} />
|
||||
<Route path="/order/:id" element={<OrderSuccessPage />} />
|
||||
<Route path="/track" element={<TrackOrderPage />} />
|
||||
<Route path="/search" element={<SearchPage />} />
|
||||
<Route path="/faq" element={<FaqPage />} />
|
||||
<Route path="/scent-guide" element={<ScentGuidePage />} />
|
||||
<Route path="/article/:slug" element={<ArticleReaderPage />} />
|
||||
<Route path="/contact" element={<ContactPage />} />
|
||||
<Route path="/about" element={<AboutPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,8 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
import Link from 'next/link'
|
||||
|
||||
const PILLARS = [
|
||||
{
|
||||
@@ -29,21 +25,19 @@ const STATS = [
|
||||
{ value: '100%', label: 'Natural Extracts' },
|
||||
]
|
||||
|
||||
export const metadata = {
|
||||
title: 'About Us | iRelax Aroma',
|
||||
description: 'Born from a passion for stillness. iRelax began in 2019 as a Bangkok studio crafting botanical aroma blends.',
|
||||
}
|
||||
|
||||
export default function AboutPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Hero */}
|
||||
<section className="relative min-h-[60vh] flex items-center justify-center overflow-hidden pt-24">
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-night-800/60 via-night-900 to-night-900" />
|
||||
<div className="absolute inset-0 opacity-10"
|
||||
<div
|
||||
className="absolute inset-0 opacity-10"
|
||||
style={{ backgroundImage: 'radial-gradient(circle at 30% 50%, rgba(102,252,241,0.3) 0%, transparent 60%), radial-gradient(circle at 80% 20%, rgba(69,162,158,0.2) 0%, transparent 50%)' }}
|
||||
/>
|
||||
<div className="relative z-10 text-center px-6 max-w-3xl mx-auto space-y-6 animate-fade-in">
|
||||
@@ -74,9 +68,7 @@ export default function AboutPage() {
|
||||
<div className="max-w-6xl mx-auto grid md:grid-cols-2 gap-16 items-center">
|
||||
<div className="space-y-6">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70">Our Mission</p>
|
||||
<h2 className="text-4xl font-bold leading-tight">
|
||||
Transforming spaces into sanctuaries of peace
|
||||
</h2>
|
||||
<h2 className="text-4xl font-bold leading-tight">Transforming spaces into sanctuaries of peace</h2>
|
||||
<p className="text-slate-300 leading-relaxed">
|
||||
We believe that scent is the fastest pathway to emotional transformation. A single breath of the right blend can dissolve anxiety, sharpen focus, or unlock creativity. Our mission is to make that transformation accessible — through products that are as beautiful as they are effective.
|
||||
</p>
|
||||
@@ -119,26 +111,16 @@ export default function AboutPage() {
|
||||
{/* CTA */}
|
||||
<section className="py-24 px-6 text-center space-y-6">
|
||||
<h2 className="text-4xl font-bold">Ready to find your scent?</h2>
|
||||
<p className="text-slate-400 max-w-md mx-auto">
|
||||
Let our Scent Guide help you discover the perfect blend for your moment.
|
||||
</p>
|
||||
<p className="text-slate-400 max-w-md mx-auto">Let our Scent Guide help you discover the perfect blend for your moment.</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-4">
|
||||
<Link
|
||||
to="/scent-guide"
|
||||
className="px-10 py-4 bg-white text-night-900 font-bold rounded-sm tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_30px_rgba(102,252,241,0.4)] transition-all duration-500"
|
||||
>
|
||||
<Link href="/scent-guide" className="px-10 py-4 bg-white text-night-900 font-bold rounded-sm tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_30px_rgba(102,252,241,0.4)] transition-all duration-500">
|
||||
Take the Scent Guide
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="px-10 py-4 glass text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
<Link href="/shop" className="px-10 py-4 glass text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300">
|
||||
Shop All Products
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { ArrowLeft } from 'lucide-react'
|
||||
import type { Article } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
async function getArticle(slug: string): Promise<Article | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/articles/${slug}`, { next: { revalidate: 300 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
if (!article) return { title: 'Article Not Found | iRelax' }
|
||||
return {
|
||||
title: `${article.title} | iRelax Scent Guide`,
|
||||
description: article.content.replace(/<[^>]+>/g, '').slice(0, 160),
|
||||
openGraph: {
|
||||
title: article.title,
|
||||
images: article.coverImage ? [article.coverImage] : [],
|
||||
type: 'article',
|
||||
publishedTime: article.createdAt,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ArticleReaderPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||
const { slug } = await params
|
||||
const article = await getArticle(slug)
|
||||
|
||||
if (!article) notFound()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-night-950">
|
||||
<main className="flex-1 pt-24 pb-20">
|
||||
<article className="max-w-4xl mx-auto w-full animate-fade-in relative mt-8">
|
||||
|
||||
{/* Desktop back button */}
|
||||
<Link
|
||||
href="/scent-guide"
|
||||
className="absolute -left-16 xl:-left-24 top-0 mt-8 hidden lg:flex p-4 glass border border-white/5 rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5 text-slate-300" />
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center px-6 py-12 space-y-6">
|
||||
<Link href="/scent-guide" className="lg:hidden text-slate-400 text-sm hover:text-white inline-flex items-center gap-2 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> Go Back
|
||||
</Link>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] font-bold text-xs">
|
||||
{new Date(article.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight max-w-3xl mx-auto font-serif">
|
||||
{article.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
{article.coverImage && (
|
||||
<div className="w-full aspect-[16/7] bg-night-900 md:rounded-[2rem] overflow-hidden shadow-2xl mb-16 border border-white/5">
|
||||
<img src={article.coverImage} alt={article.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="px-6 md:px-12 prose prose-invert prose-lg prose-cyan mx-auto max-w-3xl pb-16">
|
||||
<div dangerouslySetInnerHTML={{ __html: article.content }} className="space-y-6" />
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+22
-56
@@ -1,32 +1,24 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { API_BASE, getCategoryImage, type Category, type Product } from '../types'
|
||||
import Link from 'next/link'
|
||||
import { useParams } from 'next/navigation'
|
||||
import ProductCard from '../../../components/ProductCard'
|
||||
import { getCategoryImage, type Category, type Product } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
export default function CategoryPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const params = useParams<{ id: string }>()
|
||||
const id = params.id
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [category, setCategory] = useState<Category | null>(null)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
|
||||
// Local progressive rendering
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then((r) => r.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
// Reset state when category changes
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setDisplayCount(24)
|
||||
@@ -36,9 +28,7 @@ export default function CategoryPage() {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
Promise.all([
|
||||
// Fetch up to 1000 items (essentially all) in one go
|
||||
fetch(`${API_BASE}/categories/${id}/products?limit=1000`),
|
||||
fetch(`${API_BASE}/categories`),
|
||||
])
|
||||
@@ -46,7 +36,6 @@ export default function CategoryPage() {
|
||||
if (!catRes.ok) throw new Error('Category not found')
|
||||
const catPaginated = await catRes.json()
|
||||
const allCats: Category[] = await allCatsRes.json()
|
||||
|
||||
setCategory(catPaginated.category)
|
||||
setProducts(catPaginated.data || [])
|
||||
setCategories(allCats)
|
||||
@@ -55,58 +44,42 @@ export default function CategoryPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount) {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount((prev) => Math.min(prev + 12, products.length))
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' } // Pre-load slightly before seeing the loader
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
|
||||
if (loaderRef.current) {
|
||||
observer.observe(loaderRef.current)
|
||||
}
|
||||
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount, loading])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Hero Banner for category */}
|
||||
{/* Hero Banner */}
|
||||
<div className="relative h-64 md:h-80 overflow-hidden">
|
||||
<img
|
||||
src={getCategoryImage(category?.name ?? '')}
|
||||
src={category?.imageUrl || getCategoryImage(category?.name ?? '')}
|
||||
className="absolute inset-0 w-full h-full object-cover brightness-30 scale-105"
|
||||
alt={category?.name}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900 via-night-900/60 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 px-6 pb-10 pt-20">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest mb-4">
|
||||
<Link to="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<span>/</span>
|
||||
<span className="text-cyan-glow">{category?.name ?? '...'}</span>
|
||||
</div>
|
||||
<h1 className="text-4xl md:text-5xl font-bold">
|
||||
{loading ? (
|
||||
<div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg" />
|
||||
) : (
|
||||
category?.name
|
||||
)}
|
||||
{loading ? <div className="h-10 w-48 bg-white/10 animate-pulse rounded-lg" /> : category?.name}
|
||||
</h1>
|
||||
{!loading && category?._count && (
|
||||
<p className="text-slate-400 mt-2">
|
||||
{category._count.products} products available
|
||||
</p>
|
||||
<p className="text-slate-400 mt-2">{category._count.products} products available</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -114,12 +87,11 @@ export default function CategoryPage() {
|
||||
|
||||
{/* Products Grid */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-10">
|
||||
{/* Other Category Pills */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/category/${cat.id}`}
|
||||
href={`/category/${cat.id}`}
|
||||
className={`px-4 py-1.5 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
cat.id === category?.id
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
@@ -135,9 +107,7 @@ export default function CategoryPage() {
|
||||
<div className="text-center py-24 space-y-3">
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<p className="text-slate-400">{error}</p>
|
||||
<Link to="/shop" className="text-cyan-glow text-sm hover:underline">
|
||||
Back to Shop
|
||||
</Link>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Back to Shop</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
@@ -154,11 +124,9 @@ export default function CategoryPage() {
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invisible target for IntersectionObserver */}
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
@@ -167,8 +135,6 @@ export default function CategoryPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Contact Us | iRelax Aroma',
|
||||
description: 'Get in touch with iRelax. We respond to all inquiries within 2 hours.',
|
||||
}
|
||||
|
||||
export default function ContactLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,22 +1,16 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
|
||||
type FormState = { name: string; email: string; subject: string; message: string }
|
||||
type Status = 'idle' | 'sending' | 'sent' | 'error'
|
||||
|
||||
const SUBJECTS = ['Product Inquiry', 'Order Support', 'Custom Blending', 'Wholesale', 'Partnership', 'Other']
|
||||
|
||||
|
||||
|
||||
export default function ContactPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [form, setForm] = useState<FormState>({ name: '', email: '', subject: '', message: '' })
|
||||
const [status, setStatus] = useState<Status>('idle')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setForm(prev => ({ ...prev, [e.target.name]: e.target.value }))
|
||||
}
|
||||
@@ -24,7 +18,6 @@ export default function ContactPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setStatus('sending')
|
||||
// Simulate API call (replace with real endpoint later)
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
setStatus('sent')
|
||||
setForm({ name: '', email: '', subject: '', message: '' })
|
||||
@@ -32,15 +25,11 @@ export default function ContactPage() {
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Get in Touch</p>
|
||||
<h1 className="text-5xl font-bold">Contact Us</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">
|
||||
Have a question, custom request, or partnership idea? We'd love to hear from you.
|
||||
</p>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Have a question, custom request, or partnership idea? We'd love to hear from you.</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-6 py-16 grid md:grid-cols-5 gap-16">
|
||||
@@ -56,9 +45,7 @@ export default function ContactPage() {
|
||||
{ icon: '📍', label: 'Studio', value: 'Bangkok, Thailand', href: '#' },
|
||||
].map(item => (
|
||||
<a key={item.label} href={item.href} className="flex items-start gap-4 group">
|
||||
<div className="glass w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 group-hover:bg-cyan-glow/10 transition-colors">
|
||||
{item.icon}
|
||||
</div>
|
||||
<div className="glass w-10 h-10 rounded-xl flex items-center justify-center text-lg shrink-0 group-hover:bg-cyan-glow/10 transition-colors">{item.icon}</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">{item.label}</p>
|
||||
<p className="text-white text-sm font-medium group-hover:text-cyan-glow transition-colors">{item.value}</p>
|
||||
@@ -67,22 +54,15 @@ export default function ContactPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="glass rounded-2xl p-6 border border-white/5 space-y-3">
|
||||
<h3 className="font-bold text-sm uppercase tracking-widest">Response Time</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Email</span>
|
||||
<span className="text-cyan-glow font-medium">~2 hours</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">LINE</span>
|
||||
<span className="text-cyan-glow font-medium">~30 minutes</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-slate-400">Instagram DM</span>
|
||||
<span className="text-cyan-glow font-medium">~4 hours</span>
|
||||
</div>
|
||||
{[['Email', '~2 hours'], ['LINE', '~30 minutes'], ['Instagram DM', '~4 hours']].map(([k, v]) => (
|
||||
<div key={k} className="flex justify-between">
|
||||
<span className="text-slate-400">{k}</span>
|
||||
<span className="text-cyan-glow font-medium">{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-600 pt-2 uppercase tracking-wider">Mon–Sat, 9:00–18:00 (ICT)</p>
|
||||
</div>
|
||||
@@ -96,10 +76,7 @@ export default function ContactPage() {
|
||||
<div className="text-6xl animate-bounce">✉️</div>
|
||||
<h2 className="text-2xl font-bold">Message Sent!</h2>
|
||||
<p className="text-slate-400">Thank you for reaching out. We'll get back to you within 2 hours.</p>
|
||||
<button
|
||||
onClick={() => setStatus('idle')}
|
||||
className="mt-4 px-8 py-3 glass border border-white/10 hover:border-cyan-glow/30 font-bold uppercase tracking-widest text-xs transition-all rounded-lg"
|
||||
>
|
||||
<button onClick={() => setStatus('idle')} className="mt-4 px-8 py-3 glass border border-white/10 hover:border-cyan-glow/30 font-bold uppercase tracking-widest text-xs transition-all rounded-lg">
|
||||
Send Another
|
||||
</button>
|
||||
</div>
|
||||
@@ -109,75 +86,38 @@ export default function ContactPage() {
|
||||
<div className="grid md:grid-cols-2 gap-5">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
|
||||
<input
|
||||
required
|
||||
type="text"
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={handleChange}
|
||||
placeholder="Your name"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors"
|
||||
/>
|
||||
<input required type="text" name="name" value={form.name} onChange={handleChange} placeholder="Your name"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Email *</label>
|
||||
<input
|
||||
required
|
||||
type="email"
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={handleChange}
|
||||
placeholder="your@email.com"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors"
|
||||
/>
|
||||
<input required type="email" name="email" value={form.email} onChange={handleChange} placeholder="your@email.com"
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 transition-colors" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Subject *</label>
|
||||
<select
|
||||
required
|
||||
name="subject"
|
||||
value={form.subject}
|
||||
onChange={handleChange}
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-night-900 outline-none text-sm text-white transition-colors"
|
||||
>
|
||||
<select required name="subject" value={form.subject} onChange={handleChange}
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-night-900 outline-none text-sm text-white transition-colors">
|
||||
<option value="" disabled>Select a subject</option>
|
||||
{SUBJECTS.map(s => <option key={s} value={s}>{s}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Message *</label>
|
||||
<textarea
|
||||
required
|
||||
name="message"
|
||||
value={form.message}
|
||||
onChange={handleChange}
|
||||
rows={6}
|
||||
placeholder="Tell us about your inquiry..."
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 resize-none transition-colors"
|
||||
/>
|
||||
<textarea required name="message" value={form.message} onChange={handleChange} rows={6} placeholder="Tell us about your inquiry..."
|
||||
className="w-full glass border border-white/10 focus:border-cyan-glow/40 rounded-xl px-4 py-3.5 bg-transparent outline-none text-sm text-white placeholder-slate-600 resize-none transition-colors" />
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === 'sending'}
|
||||
className="w-full py-4 bg-white text-night-900 font-bold uppercase tracking-widest text-sm rounded-xl hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3"
|
||||
>
|
||||
<button type="submit" disabled={status === 'sending'}
|
||||
className="w-full py-4 bg-white text-night-900 font-bold uppercase tracking-widest text-sm rounded-xl hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-3">
|
||||
{status === 'sending' ? (
|
||||
<>
|
||||
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Sending...
|
||||
</>
|
||||
<><svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" /><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" /></svg>Sending...</>
|
||||
) : 'Send Message →'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { API_BASE } from '@/lib/api'
|
||||
import Link from 'next/link'
|
||||
import { Ticket, ArrowRight } from 'lucide-react'
|
||||
|
||||
// Fetch active coupons with ISR (revalidate every 60s)
|
||||
async function getActiveCoupons() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/coupons/active`, { next: { revalidate: 60 } })
|
||||
if (!res.ok) return []
|
||||
const data = await res.json()
|
||||
// Filter out ones that hit usageLimit
|
||||
return data.filter((c: any) => c.usageLimit === null || c.usedCount < c.usageLimit)
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch coupons:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function formatDiscount(type: string, value: number) {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
||||
if (type === 'FREE_SHIPPING') return `ส่งฟรี`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
export const metadata = {
|
||||
title: 'โปรโมชั่นและคูปองส่วนลด | iRelax',
|
||||
description: 'รวมรหัสส่วนลดและโปรโมชั่นพิเศษจาก iRelax สำหรับการสั่งซื้อน้ำหอมและผลิตภัณฑ์อโรม่าที่คุณชื่นชอบ',
|
||||
}
|
||||
|
||||
export default async function CouponsPage() {
|
||||
const coupons = await getActiveCoupons()
|
||||
|
||||
return (
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-white via-cyan-glow to-blue-500 bg-clip-text text-transparent">
|
||||
Exclusive Offers
|
||||
</h1>
|
||||
<p className="text-slate-400 max-w-2xl mx-auto">
|
||||
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{coupons.length === 0 ? (
|
||||
<div className="text-center py-20 bg-night-900/50 rounded-3xl border border-white/5">
|
||||
<Ticket className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">ยังไม่มีคูปองส่วนลดในขณะนี้</h2>
|
||||
<p className="text-slate-400 mb-6">กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง</p>
|
||||
<a
|
||||
href="https://irelaxaroma.shop/shop"
|
||||
className="inline-flex px-8 py-3 bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow hover:text-night-900 font-bold rounded-xl transition-all"
|
||||
>
|
||||
ไปช้อปปิ้งกันก่อน
|
||||
</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{coupons.map((coupon: any) => (
|
||||
<div
|
||||
key={coupon.id}
|
||||
className="group relative bg-night-900 border border-white/10 rounded-3xl overflow-hidden hover:border-cyan-glow/50 transition-all duration-500"
|
||||
>
|
||||
{/* Decoration */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
|
||||
<div className="p-8 space-y-6 relative z-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="w-12 h-12 rounded-xl bg-cyan-glow/10 text-cyan-glow flex items-center justify-center border border-cyan-glow/20">
|
||||
<Ticket className="w-6 h-6" />
|
||||
</div>
|
||||
{coupon.expiresAt && (
|
||||
<span className="text-[10px] font-bold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md uppercase tracking-wider">
|
||||
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-white mb-1">
|
||||
{formatDiscount(coupon.type, coupon.value)}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
เมื่อซื้อครบ {coupon.minPurchase} บาท
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-night-950 p-3 rounded-xl border border-white/5 justify-center">
|
||||
<span className="font-mono text-cyan-glow font-bold tracking-widest uppercase">
|
||||
{coupon.code}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`https://irelaxaroma.shop/shop?coupon=${coupon.code}`}
|
||||
className="w-full py-4 bg-white/5 hover:bg-cyan-glow text-white hover:text-night-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-all duration-300"
|
||||
>
|
||||
ช้อปพร้อมใช้คูปองเลย
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{/* Scalloped edge effect */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-r border-white/10" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-l border-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FAQ | iRelax Aroma',
|
||||
description: 'Everything you need to know about iRelax products, shipping, and care.',
|
||||
}
|
||||
|
||||
export default function FaqLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,84 +1,64 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
category: 'Products',
|
||||
icon: '🌿',
|
||||
category: 'Products', icon: '🌿',
|
||||
items: [
|
||||
{ q: 'What are the ingredients in your aroma products?', a: 'All iRelax products are crafted from 100% natural botanical extracts, pure essential oils, and pharmaceutical-grade carrier bases. We never use synthetic fragrances, parabens, or harmful chemicals. Each product label lists full ingredient transparency.' },
|
||||
{ q: 'How long does each product last?', a: 'Our Reed Diffusers last 4–8 weeks depending on room size and airflow. Aroma Oils (30ml) last approximately 3–4 months with normal use. Room Sprays (100ml) provide 60–80 uses. Car Diffusers cartridges last up to 45 days.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns — we are happy to help identify suitable options.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation, ideal for large rooms and commercial spaces.' },
|
||||
{ q: 'Are your products suitable for sensitive skin or allergies?', a: 'We recommend patch testing before topical use. Our Aroma Oils are formulated for diffuser use primarily. If you have known fragrance allergies, consult our scent guide or contact us with your specific concerns.' },
|
||||
{ q: 'What is the difference between Aroma Oil and Oil Ozone?', a: 'Aroma Oils are pure essential oil blends designed for aromatherapy diffusers and therapeutic use. Oil Ozone is our premium line featuring ozone-infused botanical extracts — designed for air purification and deeper space transformation.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Usage',
|
||||
icon: '💡',
|
||||
category: 'Usage', icon: '💡',
|
||||
items: [
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents for longest lasting performance.' },
|
||||
{ q: 'How do I use a Reed Diffuser?', a: 'Remove the stopper, place the reeds into the bottle, and let them absorb the oil for 1 hour before flipping. For optimal fragrance, flip the reeds every 2–3 days. Keep away from direct sunlight and air conditioning vents.' },
|
||||
{ q: 'Can I mix different scents?', a: 'Yes! Blending scents is an art form we encourage. A general rule: combine no more than 3 scents at a time, using one dominant (60%), one supporting (30%), and one accent (10%). Our Scent Guide can help you find complementary combinations.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results and to avoid olfactory fatigue, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
{ q: 'How long should I run my aroma diffuser?', a: 'For best results, we recommend 30–60 minute sessions with 30-minute breaks in between. This also extends product life and prevents overwhelming fragrance build-up in small spaces.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Shipping & Orders',
|
||||
icon: '📦',
|
||||
category: 'Shipping & Orders', icon: '📦',
|
||||
items: [
|
||||
{ q: 'How long does shipping take?', a: 'Standard delivery within Bangkok takes 1–2 business days. Provincial orders take 2–4 business days. Express same-day delivery is available in Bangkok for orders placed before 12:00 PM.' },
|
||||
{ q: 'Do you ship internationally?', a: 'Currently we ship within Thailand only. International shipping is planned for late 2025. Sign up for our newsletter to be notified when we expand to your region.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost. Contact us at hello@irelax.co.th with your order number.' },
|
||||
{ q: 'What is your return policy?', a: 'We accept returns within 14 days of purchase for unopened, undamaged products in original packaging. For defective or damaged products, we offer full replacement at no cost.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
category: 'Scent Guide',
|
||||
icon: '🧪',
|
||||
category: 'Scent Guide', icon: '🧪',
|
||||
items: [
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm, welcoming notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus. Use our interactive Scent Guide for personalized recommendations.' },
|
||||
{ q: 'How do I choose the right scent for my space?', a: 'Consider the function of the space first. Bedrooms benefit from calming scents like lavender and chamomile. Living rooms work well with warm notes like sandalwood and vanilla. Home offices pair best with focus-enhancing eucalyptus and citrus.' },
|
||||
{ q: 'Can I request custom scent blending?', a: 'Yes! Our bespoke blending service is available for orders of 6 units or more. Contact us at least 10 business days before your desired delivery date. Custom blending fees start at ฿500 per unique formula.' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function FaqPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [openItems, setOpenItems] = useState<Record<string, boolean>>({})
|
||||
const [activeCategory, setActiveCategory] = useState<string>('Products')
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const toggle = (key: string) => setOpenItems(prev => ({ ...prev, [key]: !prev[key] }))
|
||||
|
||||
const activeFaqs = FAQS.find(f => f.category === activeCategory)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Support</p>
|
||||
<h1 className="text-5xl font-bold">Frequently Asked Questions</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">
|
||||
Everything you need to know about iRelax products, shipping, and care.
|
||||
</p>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Everything you need to know about iRelax products, shipping, and care.</p>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-16 space-y-10">
|
||||
{/* Category tabs */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{FAQS.map(({ category, icon }) => (
|
||||
<button
|
||||
key={category}
|
||||
onClick={() => setActiveCategory(category)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 flex items-center gap-2 ${
|
||||
activeCategory === category
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
activeCategory === category ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span>{icon}</span> {category}
|
||||
@@ -86,25 +66,15 @@ export default function FaqPage() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* FAQ Accordion */}
|
||||
<div className="space-y-3">
|
||||
{activeFaqs?.items.map((item, i) => {
|
||||
const key = `${activeCategory}-${i}`
|
||||
const isOpen = openItems[key]
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`glass rounded-xl overflow-hidden border transition-all duration-300 ${isOpen ? 'border-cyan-glow/20' : 'border-white/5'}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => toggle(key)}
|
||||
className="w-full text-left px-6 py-5 flex justify-between items-start gap-4"
|
||||
>
|
||||
<div key={key} className={`glass rounded-xl overflow-hidden border transition-all duration-300 ${isOpen ? 'border-cyan-glow/20' : 'border-white/5'}`}>
|
||||
<button onClick={() => toggle(key)} className="w-full text-left px-6 py-5 flex justify-between items-start gap-4">
|
||||
<span className="font-semibold text-sm md:text-base leading-snug">{item.q}</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-cyan-glow shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`}
|
||||
fill="none" viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<svg className={`w-5 h-5 text-cyan-glow shrink-0 transition-transform duration-300 ${isOpen ? 'rotate-45' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
</button>
|
||||
@@ -119,21 +89,15 @@ export default function FaqPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Still have questions? */}
|
||||
<div className="glass rounded-2xl p-8 text-center space-y-4 border border-white/5 mt-12">
|
||||
<div className="text-3xl">💬</div>
|
||||
<h3 className="font-bold text-xl">Still have questions?</h3>
|
||||
<p className="text-slate-400 text-sm">Our team is here to help — usually responds within 2 hours.</p>
|
||||
<a
|
||||
href="/contact"
|
||||
className="inline-block px-8 py-3 bg-white text-night-900 font-bold rounded-lg uppercase tracking-widest text-xs hover:bg-cyan-glow transition-colors duration-300"
|
||||
>
|
||||
<a href="/contact" className="inline-block px-8 py-3 bg-white text-night-900 font-bold rounded-lg uppercase tracking-widest text-xs hover:bg-cyan-glow transition-colors duration-300">
|
||||
Contact Us
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
File renamed without changes.
@@ -0,0 +1,45 @@
|
||||
import type { Metadata } from 'next';
|
||||
import './globals.css';
|
||||
import Navbar from '../components/Navbar';
|
||||
import Footer from '../components/Footer';
|
||||
import ClientProviders from '../components/ClientProviders';
|
||||
import { API_BASE } from '../lib/api';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'iRelax Aroma',
|
||||
description: 'Pure Origins, Artisan Craft, Zero Compromise',
|
||||
};
|
||||
|
||||
// Fetch categories for Navbar and Footer on the server
|
||||
async function getCategories() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate: 3600 } });
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch categories in layout', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const categories = await getCategories();
|
||||
|
||||
return (
|
||||
<html lang="en" className="scroll-smooth">
|
||||
<body className="bg-night-900 text-silver-metallic font-sans antialiased min-h-screen">
|
||||
<ClientProviders>
|
||||
<Navbar categories={categories} />
|
||||
<main className="pt-[72px]">
|
||||
{children}
|
||||
</main>
|
||||
<Footer categories={categories} />
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,46 +1,48 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { API_BASE, getCategoryImage, type Category, type Product, type Hero } from '../types'
|
||||
import { getCategoryImage, type Category, type Product, type Hero } from '@homaroma/types'
|
||||
import { API_BASE } from '../lib/api'
|
||||
|
||||
export default function HomePage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [featuredProducts, setFeaturedProducts] = useState<Product[]>([])
|
||||
const [hero, setHero] = useState<Hero | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
// Cache settings for Next.js fetch
|
||||
const revalidate = 60; // Revalidate cache every 60 seconds
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
const [catRes, prodRes, heroRes] = await Promise.all([
|
||||
fetch(`${API_BASE}/categories`),
|
||||
fetch(`${API_BASE}/products?categoryId=1`),
|
||||
fetch(`${API_BASE}/hero`),
|
||||
])
|
||||
const cats: Category[] = await catRes.json()
|
||||
const prodsJson = await prodRes.json()
|
||||
const heroText = await heroRes.text()
|
||||
const heroData: Hero | null = heroText ? JSON.parse(heroText) : null
|
||||
|
||||
const prods: Product[] = prodsJson.data || []
|
||||
setCategories(cats)
|
||||
setFeaturedProducts(prods.slice(0, 3))
|
||||
setHero(heroData)
|
||||
} catch (err) {
|
||||
console.error('API error:', err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
async function getCategories(): Promise<Category[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/categories`, { next: { revalidate } })
|
||||
if (!res.ok) return []
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async function getFeaturedProducts(): Promise<Product[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?categoryId=1`, { next: { revalidate } })
|
||||
if (!res.ok) return []
|
||||
const text = await res.text()
|
||||
const json = text ? JSON.parse(text) : { data: [] }
|
||||
return json?.data?.slice(0, 3) || []
|
||||
} catch (e) { return [] }
|
||||
}
|
||||
|
||||
async function getHero(): Promise<Hero | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/hero`, { next: { revalidate } })
|
||||
if (!res.ok) return null
|
||||
const text = await res.text()
|
||||
return text ? JSON.parse(text) : null
|
||||
} catch (e) { return null }
|
||||
}
|
||||
|
||||
export default async function HomePage() {
|
||||
const [categories, featuredProducts, hero] = await Promise.all([
|
||||
getCategories(),
|
||||
getFeaturedProducts(),
|
||||
getHero(),
|
||||
])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Hero Section */}
|
||||
<section className="relative w-full min-h-[90vh] md:min-h-0 md:aspect-video flex items-center justify-center overflow-hidden bg-night-950">
|
||||
<div className="absolute inset-0 z-0">
|
||||
@@ -48,9 +50,6 @@ export default function HomePage() {
|
||||
src={hero?.imageUrl || "/images/hero.png"}
|
||||
className="w-full h-full object-cover scale-[1.02] brightness-50"
|
||||
alt="Hero Background"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900 via-transparent to-night-900/50" />
|
||||
</div>
|
||||
@@ -89,17 +88,17 @@ export default function HomePage() {
|
||||
</p>
|
||||
<div className="pt-8 flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link
|
||||
to={hero?.buttonLink || "/shop"}
|
||||
href={hero?.buttonLink || "/shop"}
|
||||
className="px-10 py-4 bg-white text-night-900 font-bold rounded-sm tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_30px_rgba(102,252,241,0.5)] transition-all duration-500"
|
||||
>
|
||||
{hero?.buttonText || "Explore Collection"}
|
||||
</Link>
|
||||
<a
|
||||
<Link
|
||||
href="#categories"
|
||||
className="px-10 py-4 glass text-white font-bold rounded-sm tracking-widest uppercase hover:bg-white/10 transition-all duration-300"
|
||||
>
|
||||
Learn More
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -119,46 +118,37 @@ export default function HomePage() {
|
||||
<div className="w-12 h-px bg-cyan-glow mx-auto mt-3" />
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="h-56 rounded-xl bg-white/5 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/category/${cat.id}`}
|
||||
className="relative h-56 rounded-xl overflow-hidden group border-2 border-white/5 hover:border-cyan-glow/50 hover:shadow-[0_0_20px_rgba(102,252,241,0.15)] transition-all duration-500"
|
||||
>
|
||||
<img
|
||||
src={getCategoryImage(cat.name)}
|
||||
className="absolute inset-0 w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:scale-110 group-hover:opacity-70 transition-all duration-700"
|
||||
alt={cat.name}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<span className="text-white font-bold tracking-wider uppercase text-xs block">
|
||||
{cat.name}
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
href={`/category/${cat.id}`}
|
||||
className="relative h-56 rounded-xl overflow-hidden group border-2 border-white/5 hover:border-cyan-glow/50 hover:shadow-[0_0_20px_rgba(102,252,241,0.15)] transition-all duration-500"
|
||||
>
|
||||
<img
|
||||
src={cat.imageUrl || getCategoryImage(cat.name)}
|
||||
className="absolute inset-0 w-full h-full object-cover grayscale opacity-40 group-hover:grayscale-0 group-hover:scale-110 group-hover:opacity-70 transition-all duration-700"
|
||||
alt={cat.name}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900/90 via-night-900/20 to-transparent" />
|
||||
<div className="absolute bottom-0 left-0 right-0 p-4">
|
||||
<span className="text-white font-bold tracking-wider uppercase text-xs block">
|
||||
{cat.name}
|
||||
</span>
|
||||
{cat._count && (
|
||||
<span className="text-cyan-glow/60 text-[10px] tracking-wider">
|
||||
{cat._count.products} products
|
||||
</span>
|
||||
{cat._count && (
|
||||
<span className="text-cyan-glow/60 text-[10px] tracking-wider">
|
||||
{cat._count.products} products
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg className="w-5 h-5 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
<div className="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<svg className="w-5 h-5 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
|
||||
</svg>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -173,30 +163,20 @@ export default function HomePage() {
|
||||
<h2 className="text-4xl font-bold">Featured Products</h2>
|
||||
</div>
|
||||
<Link
|
||||
to="/shop"
|
||||
href="/shop"
|
||||
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline decoration-cyan-glow/30"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{featuredProducts.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{featuredProducts.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import AddToCartSection from '../../../components/AddToCartSection'
|
||||
import ProductCard from '../../../components/ProductCard'
|
||||
|
||||
import { API_BASE } from '../../../lib/api'
|
||||
|
||||
async function getProduct(id: string): Promise<Product | null> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products/${id}`, { next: { revalidate: 60 } })
|
||||
return res.ok ? res.json() : null
|
||||
} catch { return null }
|
||||
}
|
||||
|
||||
async function getRelated(categoryId: number, excludeId: number): Promise<Product[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?categoryId=${categoryId}`, { next: { revalidate: 60 } })
|
||||
const json = res.ok ? await res.json() : { data: [] }
|
||||
return (json.data || []).filter((p: Product) => p.id !== excludeId).slice(0, 6)
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
// Generate dynamic SEO metadata per product
|
||||
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
|
||||
const { id } = await params
|
||||
const product = await getProduct(id)
|
||||
if (!product) return { title: 'Product Not Found | iRelax' }
|
||||
return {
|
||||
title: `${product.scent.name} ${product.category.name} | iRelax Aroma`,
|
||||
description: product.description ?? `Premium ${product.category.name} — ${product.scent.name}. Shop iRelax Aroma for high-quality botanical scents.`,
|
||||
openGraph: {
|
||||
title: product.scent.name,
|
||||
description: product.description ?? '',
|
||||
images: product.images?.[0]?.url ? [product.images[0].url] : [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export default async function ProductDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params
|
||||
const [product, related] = await Promise.all([
|
||||
getProduct(id),
|
||||
getProduct(id).then(p => p ? getRelated(p.category.id, p.id) : []),
|
||||
])
|
||||
|
||||
if (!product) notFound()
|
||||
|
||||
const firstVariant = product.variants[0]
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0">
|
||||
<div className="max-w-7xl mx-auto px-6 pt-32 pb-24 space-y-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest">
|
||||
<Link href="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link href={`/category/${product.category.id}`} className="hover:text-white transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-cyan-glow">{product.scent.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Product Detail */}
|
||||
<div className="grid md:grid-cols-2 gap-16 items-center">
|
||||
{/* Image */}
|
||||
<div className="relative rounded-2xl overflow-hidden h-[480px] group">
|
||||
<img
|
||||
src={product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900/50 to-transparent" />
|
||||
<div className="absolute top-6 left-6 glass px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow border border-cyan-glow/20">
|
||||
{product.category.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info — Interactive (Add to Cart, Variant select) must be Client */}
|
||||
<AddToCartSection product={product} />
|
||||
</div>
|
||||
|
||||
{/* Full Details Section */}
|
||||
{product.details && (
|
||||
<div className="pt-16 border-t border-white/5 space-y-6">
|
||||
<h2 className="text-3xl font-bold">Product Details</h2>
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-4xl product-editorial"
|
||||
dangerouslySetInnerHTML={{ __html: product.details }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Products */}
|
||||
{related.length > 0 && (
|
||||
<div className="space-y-8 border-t border-white/5 pt-16">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70 mb-2">
|
||||
More {product.category.name}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold">Related Products</h2>
|
||||
</div>
|
||||
<Link
|
||||
href={`/category/${product.category.id}`}
|
||||
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+21
-24
@@ -1,29 +1,29 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category, type Article } from '../types'
|
||||
import Link from 'next/link'
|
||||
import type { Article } from '@homaroma/types'
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
export default function ScentGuidePage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [articles, setArticles] = useState<Article[]>([])
|
||||
export const metadata = {
|
||||
title: 'Scent Guide | iRelax Aroma',
|
||||
description: 'Explore our collection of aroma articles, guides, and sensory knowledge to find your perfect scent.',
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
fetch(`${API_BASE}/articles?publishedOnly=true`).then(r => r.json()).then(setArticles).catch(console.error)
|
||||
}, [])
|
||||
async function getArticles(): Promise<Article[]> {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/articles?publishedOnly=true`, { next: { revalidate: 60 } })
|
||||
return res.ok ? res.json() : []
|
||||
} catch { return [] }
|
||||
}
|
||||
|
||||
export default async function ScentGuidePage() {
|
||||
const articles = await getArticles()
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-16 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/20">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Journal</p>
|
||||
<h1 className="text-5xl font-bold">Scent Guide</h1>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">
|
||||
Explore our collection of articles, guides, and sensory knowledge.
|
||||
</p>
|
||||
<p className="text-slate-400 max-w-lg mx-auto">Explore our collection of articles, guides, and sensory knowledge.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 max-w-7xl mx-auto px-6 py-16 w-full">
|
||||
@@ -34,9 +34,9 @@ export default function ScentGuidePage() {
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{articles.map(article => (
|
||||
<Link
|
||||
key={article.id}
|
||||
to={`/article/${article.slug}`}
|
||||
<Link
|
||||
key={article.id}
|
||||
href={`/article/${article.slug}`}
|
||||
className="group block bg-night-950 rounded-2xl overflow-hidden border border-white/5 hover:border-cyan-glow/30 transition-all duration-300 hover:-translate-y-1 hover:shadow-2xl flex flex-col"
|
||||
>
|
||||
<div className="aspect-[4/3] bg-night-900 overflow-hidden shrink-0">
|
||||
@@ -52,8 +52,7 @@ export default function ScentGuidePage() {
|
||||
<p className="text-[10px] text-cyan-glow font-bold tracking-widest uppercase">
|
||||
{new Date(article.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
<h3 className="text-xl font-bold leading-tight group-hover:text-cyan-glow transition-colors">{article.title}</h3>
|
||||
{/* Remove HTML tags for purely excerpt preview */}
|
||||
<h2 className="text-xl font-bold leading-tight group-hover:text-cyan-glow transition-colors">{article.title}</h2>
|
||||
<div className="text-slate-400 text-sm line-clamp-3" dangerouslySetInnerHTML={{ __html: article.content }} />
|
||||
</div>
|
||||
</Link>
|
||||
@@ -61,8 +60,6 @@ export default function ScentGuidePage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Search Products | iRelax Aroma',
|
||||
description: 'Search our collection of premium aroma products by scent or product name.',
|
||||
}
|
||||
|
||||
export default function SearchLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client"
|
||||
import { useState, useEffect, useRef, Suspense } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../../components/ProductCard'
|
||||
import { type Product } from '@homaroma/types'
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
function SearchResults() {
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const q = searchParams.get('q') || ''
|
||||
|
||||
const [query, setQuery] = useState(q)
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Sync input → URL
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (query.trim()) router.replace(`/search?q=${encodeURIComponent(query.trim())}`)
|
||||
}, 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [query, router])
|
||||
|
||||
// Fetch on URL change
|
||||
useEffect(() => {
|
||||
if (!q.trim()) { setProducts([]); return }
|
||||
setLoading(true)
|
||||
setDisplayCount(24)
|
||||
fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}&limit=1000`)
|
||||
.then(r => r.json())
|
||||
.then(data => setProducts(data.data || []))
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [q])
|
||||
|
||||
// Infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount(prev => Math.min(prev + 12, products.length))
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Header */}
|
||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">Search</p>
|
||||
<h1 className="text-5xl font-bold tracking-tight">Find Your Scent</h1>
|
||||
|
||||
<div className="max-w-md mx-auto mt-6">
|
||||
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
|
||||
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
placeholder="Search by scent or product name..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
className="bg-transparent border-none outline-none text-sm p-2 w-full text-white placeholder-slate-500"
|
||||
/>
|
||||
{query && (
|
||||
<button
|
||||
onClick={() => { setQuery(''); router.replace('/search') }}
|
||||
className="px-3 text-slate-400 hover:text-white transition-colors"
|
||||
>✕</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && q && (
|
||||
<p className="text-slate-500 text-sm">
|
||||
{products.length > 0
|
||||
? `Found ${products.length} result${products.length !== 1 ? 's' : ''} for "${q}"`
|
||||
: `No results for "${q}"`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{!q.trim() ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-3">
|
||||
<p className="text-4xl">🔍</p>
|
||||
<p className="text-lg">Type something to search</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
</div>
|
||||
) : loading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : products.length === 0 ? (
|
||||
<div className="text-center py-24 text-slate-500 space-y-4">
|
||||
<p className="text-4xl">🌿</p>
|
||||
<p className="text-lg font-semibold">No products found</p>
|
||||
<p className="text-sm">Try a different scent name or keyword</p>
|
||||
<Link href="/shop" className="text-cyan-glow text-sm hover:underline">Browse All Products →</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{products.slice(0, displayCount).map(p => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SearchResults />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Shop All Products | iRelax Aroma',
|
||||
description: 'Browse our full range of premium aroma products — crafted to elevate every space.',
|
||||
}
|
||||
|
||||
export default function ShopLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
"use client";
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Link, useLocation } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { API_BASE, type Category, type Product } from '../types'
|
||||
import Link from 'next/link'
|
||||
import ProductCard from '../../components/ProductCard'
|
||||
import { type Category, type Product } from '@homaroma/types'
|
||||
|
||||
import { API_BASE } from '../../lib/api'
|
||||
|
||||
export default function ShopPage() {
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
@@ -11,104 +12,64 @@ export default function ShopPage() {
|
||||
const [activeCategory, setActiveCategory] = useState<number | null>(null)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const location = useLocation()
|
||||
|
||||
// Local progressive rendering
|
||||
const [displayCount, setDisplayCount] = useState(24)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const [totalProducts, setTotalProducts] = useState(0)
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [productsLoading, setProductsLoading] = useState(false)
|
||||
const loaderRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Handle coupon from URL
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(location.search)
|
||||
const coupon = params.get('coupon')
|
||||
if (coupon) {
|
||||
localStorage.setItem('irelax_active_coupon', coupon)
|
||||
// Optionally clean URL
|
||||
window.history.replaceState({}, document.title, window.location.pathname)
|
||||
}
|
||||
}, [location])
|
||||
|
||||
// Fetch categories
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then((r) => r.json())
|
||||
.then((data: Category[]) => {
|
||||
setCategories(data)
|
||||
setLoading(false)
|
||||
})
|
||||
.then((data: Category[]) => { setCategories(data); setLoading(false) })
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
// Debounce search input
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setSearchQuery(searchInput)
|
||||
}, 400)
|
||||
const timer = setTimeout(() => setSearchQuery(searchInput), 400)
|
||||
return () => clearTimeout(timer)
|
||||
}, [searchInput])
|
||||
|
||||
// Reset scroll and displayCount when category or search changes
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
setDisplayCount(24)
|
||||
}, [activeCategory, searchQuery])
|
||||
|
||||
// Fetch products
|
||||
useEffect(() => {
|
||||
setProductsLoading(true)
|
||||
|
||||
const params = new URLSearchParams()
|
||||
params.append('limit', '1000') // fetch all matching items locally
|
||||
params.append('limit', '1000')
|
||||
if (activeCategory) params.append('categoryId', activeCategory.toString())
|
||||
if (searchQuery.trim()) params.append('search', searchQuery.trim())
|
||||
|
||||
fetch(`${API_BASE}/products?${params.toString()}`)
|
||||
.then((r) => r.json())
|
||||
.then((data: { data: Product[], meta?: any }) => {
|
||||
setProducts(data.data || [])
|
||||
if (data.meta) {
|
||||
setTotalProducts(data.meta.total || 0)
|
||||
}
|
||||
setTotalProducts(data.meta?.total || 0)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setProductsLoading(false))
|
||||
}, [activeCategory, searchQuery])
|
||||
|
||||
// Intersection Observer for infinite scroll
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting && products.length > displayCount) {
|
||||
if (entries[0].isIntersecting && products.length > displayCount)
|
||||
setDisplayCount((prev) => Math.min(prev + 12, products.length))
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1, rootMargin: '200px' }
|
||||
)
|
||||
|
||||
if (loaderRef.current) observer.observe(loaderRef.current)
|
||||
return () => observer.disconnect()
|
||||
}, [products.length, displayCount, productsLoading])
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="pt-32 pb-12 px-6 text-center space-y-4 border-b border-white/5 bg-night-800/30">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">
|
||||
All Products
|
||||
</p>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">All Products</p>
|
||||
<h1 className="text-5xl font-bold tracking-tight">The Collection</h1>
|
||||
<p className="text-slate-400 max-w-md mx-auto">
|
||||
Browse our full range of premium aroma products — crafted to elevate every space.
|
||||
</p>
|
||||
|
||||
{/* Search */}
|
||||
<p className="text-slate-400 max-w-md mx-auto">Browse our full range of premium aroma products — crafted to elevate every space.</p>
|
||||
<div className="max-w-md mx-auto mt-6">
|
||||
<div className="flex glass border border-white/10 rounded-xl overflow-hidden">
|
||||
<svg className="w-4 h-4 text-slate-400 m-3.5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@@ -131,49 +92,34 @@ export default function ShopPage() {
|
||||
<button
|
||||
onClick={() => setActiveCategory(null)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
activeCategory === null
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
activeCategory === null ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
>All</button>
|
||||
{categories.map((cat) => (
|
||||
<button
|
||||
key={cat.id}
|
||||
onClick={() => setActiveCategory(cat.id)}
|
||||
className={`px-5 py-2 rounded-full text-xs font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
activeCategory === cat.id
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
activeCategory === cat.id ? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.3)]' : 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{cat.name}
|
||||
{cat._count && (
|
||||
<span className="ml-2 opacity-60">({cat._count.products})</span>
|
||||
)}
|
||||
{cat._count && <span className="ml-2 opacity-60">({cat._count.products})</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Results count */}
|
||||
{(!loading) && (
|
||||
{!loading && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-slate-500 text-sm">
|
||||
{productsLoading ? 'Loading...' : `${totalProducts} products`}
|
||||
</p>
|
||||
<p className="text-slate-500 text-sm">{productsLoading ? 'Loading...' : `${totalProducts} products`}</p>
|
||||
{activeCategory && (
|
||||
<Link
|
||||
to={`/category/${activeCategory}`}
|
||||
className="text-cyan-glow text-xs font-bold uppercase tracking-widest hover:underline"
|
||||
>
|
||||
<Link href={`/category/${activeCategory}`} className="text-cyan-glow text-xs font-bold uppercase tracking-widest hover:underline">
|
||||
View Category Page →
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Products Grid */}
|
||||
{loading || productsLoading ? (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{Array.from({ length: 9 }).map((_, i) => (
|
||||
@@ -193,8 +139,6 @@ export default function ShopPage() {
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Invisible target for IntersectionObserver */}
|
||||
{displayCount < products.length && (
|
||||
<div ref={loaderRef} className="py-12 flex justify-center">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-cyan-glow border-t-transparent animate-spin" />
|
||||
@@ -203,8 +147,6 @@ export default function ShopPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export default function TrackPage() {
|
||||
redirect('/')
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="77" height="47" fill="none" aria-labelledby="vite-logo-title" viewBox="0 0 77 47"><title id="vite-logo-title">Vite</title><style>.parenthesis{fill:#000}@media (prefers-color-scheme:dark){.parenthesis{fill:#fff}}</style><path fill="#9135ff" d="M40.151 45.71c-.663.844-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.493c-.92 0-1.457-1.04-.92-1.788l7.479-10.471c1.07-1.498 0-3.578-1.842-3.578H15.443c-.92 0-1.456-1.04-.92-1.788l9.696-13.576c.213-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.472c-1.07 1.497 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.087.89 1.83L40.153 45.712z"/><mask id="a" width="48" height="47" x="14" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M40.047 45.71c-.663.843-2.02.374-2.02-.699V34.708a2.26 2.26 0 0 0-2.262-2.262H24.389c-.92 0-1.457-1.04-.92-1.788l7.479-10.472c1.07-1.497 0-3.578-1.842-3.578H15.34c-.92 0-1.456-1.04-.92-1.788l9.696-13.575c.213-.297.556-.474.92-.474H53.93c.92 0 1.456 1.04.92 1.788L47.37 13.03c-1.07 1.498 0 3.578 1.842 3.578h11.376c.944 0 1.474 1.088.89 1.831L40.049 45.712z"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#eee6ff" rx="5.508" ry="14.704" transform="rotate(269.814 20.96 11.29)scale(-1 1)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#eee6ff" rx="10.399" ry="29.851" transform="rotate(89.814 -16.902 -8.275)scale(1 -1)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#8900ff" rx="5.508" ry="30.487" transform="rotate(89.814 -19.197 -7.127)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.928 4.177)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#8900ff" rx="5.508" ry="30.599" transform="rotate(89.814 -25.738 5.52)scale(1 -1)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#eee6ff" rx="14.072" ry="22.078" transform="rotate(93.35 31.245 55.578)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#8900ff" rx="3.47" ry="21.501" transform="rotate(89.009 35.419 55.202)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx="14.592" cy="9.743" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(39.51 14.592 9.743)"/></g><g filter="url(#k)"><ellipse cx="61.728" cy="-5.321" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 61.728 -5.32)"/></g><g filter="url(#l)"><ellipse cx="55.618" cy="7.104" fill="#00c2ff" rx="5.971" ry="9.665" transform="rotate(37.892 55.618 7.104)"/></g><g filter="url(#m)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#n)"><ellipse cx="12.326" cy="39.103" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 12.326 39.103)"/></g><g filter="url(#o)"><ellipse cx="49.857" cy="30.678" fill="#8900ff" rx="4.407" ry="29.108" transform="rotate(37.892 49.857 30.678)"/></g><g filter="url(#p)"><ellipse cx="52.623" cy="33.171" fill="#00c2ff" rx="5.971" ry="15.297" transform="rotate(37.892 52.623 33.17)"/></g></g><path d="M6.919 0c-9.198 13.166-9.252 33.575 0 46.789h6.215c-9.25-13.214-9.196-33.623 0-46.789zm62.424 0h-6.215c9.198 13.166 9.252 33.575 0 46.789h6.215c9.25-13.214 9.196-33.623 0-46.789" class="parenthesis"/><defs><filter id="b" width="60.045" height="41.654" x="-5.564" y="16.92" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-40.407" y="-6.762" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="7.659"/></filter><filter id="d" width="79.355" height="29.4" x="-35.435" y="2.801" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="e" width="79.579" height="29.4" x="-30.84" y="20.8" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17286" stdDeviation="4.596"/></filter><filter id="f" width="79.579" height="29.4" x="-29.307" y="21.949" color-interpolation-filters="sRGB" filterUnits="usLine truncated
|
||||
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
import { useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { getCategoryImage, formatPrice, type Product } from '@homaroma/types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
|
||||
interface Props {
|
||||
product: Product
|
||||
}
|
||||
|
||||
export default function AddToCartSection({ product }: Props) {
|
||||
const [selectedVariant, setSelectedVariant] = useState(0)
|
||||
const { addToCart, toggleCart } = useCartStore()
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!currentVariant) return
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: currentVariant.id,
|
||||
quantity: 1,
|
||||
price: currentVariant.price || product.price || 0,
|
||||
productName: product.scent.name,
|
||||
variantSize: currentVariant.size,
|
||||
productImage: product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(product.category.name),
|
||||
})
|
||||
toggleCart(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Desktop Info Panel */}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-80">
|
||||
{product.category.name}
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold leading-tight">{product.scent.name}</h1>
|
||||
<p className="text-slate-400 text-sm uppercase tracking-widest">Premium Collection</p>
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
<p className="text-slate-300 leading-relaxed">{product.description}</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-cyan-glow">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
</span>
|
||||
{currentVariant && (
|
||||
<span className="text-slate-400 text-sm">/ {currentVariant.size}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variant Selector */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400 font-semibold">Select Size</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
selectedVariant === i
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.4)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{v.size}
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-night-800' : 'text-slate-400'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
<button className="glass p-4 rounded-lg hover:bg-white/10 transition-all">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="border-t border-white/5 pt-6 grid grid-cols-2 gap-4 text-sm text-slate-400">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1">Scent</span>
|
||||
<span className="text-white font-medium">{product.scent.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1">Category</span>
|
||||
<Link href={`/category/${product.category.id}`} className="text-white font-medium hover:text-cyan-glow transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sticky Add to Cart Bar */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-night-950/95 backdrop-blur-xl border-t border-white/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.5)]">
|
||||
{product.variants.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none' }}>
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={`mobile-v-${v.id}`}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`flex-shrink-0 px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap ${
|
||||
selectedVariant === i
|
||||
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
|
||||
: 'bg-white/5 border-white/10 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{v.size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-400">
|
||||
{product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-cyan-glow">{formatPrice(currentVariant?.price)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-3.5 bg-cyan-glow text-night-900 font-bold rounded-lg tracking-widest uppercase hover:brightness-110 shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import React from 'react'
|
||||
import { X, ShoppingBag, Plus, Minus, Trash2, Tag, ArrowRight } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
"use client";
|
||||
import { X, ShoppingBag, Plus, Minus, Trash2 } from 'lucide-react'
|
||||
import Link from 'next/link'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { formatPrice } from '@homaroma/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { API_BASE } from '../types'
|
||||
|
||||
export default function CartDrawer() {
|
||||
const { t } = useTranslation()
|
||||
@@ -40,7 +39,7 @@ export default function CartDrawer() {
|
||||
</div>
|
||||
|
||||
{/* Cart Items */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{items.length === 0 ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-slate-500 space-y-4">
|
||||
<ShoppingBag className="w-16 h-16 opacity-20" />
|
||||
@@ -55,7 +54,7 @@ export default function CartDrawer() {
|
||||
) : (
|
||||
items.map((item) => (
|
||||
<div key={item.cartItemId} className="flex gap-4 p-4 rounded-2xl bg-white/5 border border-white/5 group relative">
|
||||
<div className="w-18 h-22 rounded-lg bg-night-950 overflow-hidden flex-shrink-0" style={{ width: '72px', height: '88px' }}>
|
||||
<div className="w-20 h-24 rounded-lg bg-night-950 overflow-hidden flex-shrink-0">
|
||||
<img
|
||||
src={item.productImage || '/images/oil_ozone.png'}
|
||||
alt={item.productName}
|
||||
@@ -63,46 +62,34 @@ export default function CartDrawer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0">
|
||||
<h3 className="font-bold text-sm leading-tight pr-6 truncate">{item.productName}</h3>
|
||||
<div className="flex-1 flex flex-col pt-1 space-y-1 relative">
|
||||
<h3 className="font-bold text-sm leading-tight pr-6">{item.productName}</h3>
|
||||
{item.variantSize && (
|
||||
<p className="text-xs text-slate-500">{item.variantSize}</p>
|
||||
<p className="text-xs text-slate-400">{item.variantSize}</p>
|
||||
)}
|
||||
|
||||
{/* Price row */}
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-xs text-slate-500">{formatPrice(item.price)}</span>
|
||||
<span className="text-xs text-slate-600">×</span>
|
||||
<span className="text-xs text-slate-500">{item.quantity}</span>
|
||||
<span className="text-xs text-slate-600">=</span>
|
||||
<span className="text-sm font-bold text-cyan-glow">{formatPrice(item.price * item.quantity)}</span>
|
||||
</div>
|
||||
<p className="text-cyan-glow text-sm font-bold mt-auto">{formatPrice(item.price)}</p>
|
||||
|
||||
{/* Quantity controls */}
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex items-center gap-2 bg-night-950/70 rounded-lg border border-white/5">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||||
className="p-1.5 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</button>
|
||||
<span className="w-5 text-center text-xs font-bold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||||
className="p-1.5 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-2 bg-night-950/50 rounded-lg w-max border border-white/5">
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||||
className="p-1 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Minus className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="w-4 text-center text-xs font-bold">{item.quantity}</span>
|
||||
<button
|
||||
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||||
className="p-1 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => removeFromCart(item.cartItemId)}
|
||||
className="absolute top-0 right-0 p-1.5 text-slate-600 hover:text-rose-500 transition-colors"
|
||||
title="Remove item"
|
||||
className="absolute top-1 right-0 p-1 text-slate-500 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,103 +99,22 @@ export default function CartDrawer() {
|
||||
|
||||
{/* Footer */}
|
||||
{items.length > 0 && (
|
||||
<CartDrawerFooter subtotal={getCartTotal()} onClose={() => toggleCart(false)} t={t} />
|
||||
<div className="p-6 border-t border-white/10 bg-night-950/50 space-y-6">
|
||||
<div className="flex items-center justify-between text-lg font-bold">
|
||||
<span>{t('cart.subtotal')}</span>
|
||||
<span className="text-cyan-glow">{formatPrice(getCartTotal())}</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 text-center">{t('cart.shippingNote')}</p>
|
||||
<Link
|
||||
href="/checkout"
|
||||
onClick={() => toggleCart(false)}
|
||||
className="btn-primary w-full py-4 text-center block uppercase tracking-widest text-sm"
|
||||
>
|
||||
{t('cart.proceed')}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CartDrawerFooter({ subtotal, onClose }: { subtotal: number; onClose: () => void; t?: any }) {
|
||||
const [savedCoupon, setSavedCoupon] = React.useState<string | null>(null)
|
||||
const [discountAmount, setDiscountAmount] = React.useState<number>(0)
|
||||
const [isFreeShipping, setIsFreeShipping] = React.useState<boolean>(false)
|
||||
const [couponValid, setCouponValid] = React.useState<boolean>(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
const code = localStorage.getItem('irelax_active_coupon')
|
||||
setSavedCoupon(code)
|
||||
if (code && subtotal > 0) {
|
||||
fetch(`${API_BASE}/coupons/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, cartTotal: subtotal })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.valid) {
|
||||
setCouponValid(true)
|
||||
setDiscountAmount(data.discountAmount || 0)
|
||||
setIsFreeShipping(data.isFreeShipping || false)
|
||||
} else {
|
||||
setCouponValid(false)
|
||||
}
|
||||
})
|
||||
.catch(() => setCouponValid(false))
|
||||
}
|
||||
}, [subtotal])
|
||||
|
||||
const removeCoupon = () => {
|
||||
localStorage.removeItem('irelax_active_coupon')
|
||||
setSavedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponValid(false)
|
||||
}
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="p-5 border-t border-white/10 bg-night-950/60 space-y-4">
|
||||
|
||||
{/* Totals */}
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-slate-400">
|
||||
<span>ราคาสินค้า</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
{(discountAmount > 0 || isFreeShipping) && savedCoupon && (
|
||||
<div className="flex justify-between text-cyan-glow">
|
||||
<span className="flex items-center gap-1.5">
|
||||
ส่วนลดคูปอง
|
||||
<span className="text-[10px] font-bold bg-cyan-glow/15 px-1.5 py-0.5 rounded tracking-widest">
|
||||
{savedCoupon}
|
||||
</span>
|
||||
<button onClick={removeCoupon} className="text-slate-500 hover:text-rose-400 transition-colors" title="ยกเลิก">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
<span>{isFreeShipping ? 'ส่งฟรี' : `- ${formatPrice(discountAmount)}`}</span>
|
||||
</div>
|
||||
)}
|
||||
{savedCoupon && !couponValid && (
|
||||
<div className="flex justify-between text-amber-400 text-xs">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Tag className="w-3 h-3" />
|
||||
คูปอง <span className="font-bold tracking-widest">{savedCoupon}</span> (ยังไม่ถึงยอดขั้นต่ำ)
|
||||
<button onClick={removeCoupon} className="text-slate-500 hover:text-rose-400 transition-colors">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold text-base pt-2 border-t border-white/10">
|
||||
<span>รวมสุทธิ</span>
|
||||
<span className="text-cyan-glow">{formatPrice(Math.max(0, subtotal - discountAmount))}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Checkout button */}
|
||||
<Link
|
||||
to="/checkout"
|
||||
onClick={onClose}
|
||||
className="flex items-center justify-center gap-2 w-full py-4 bg-white text-night-900 font-bold rounded-xl uppercase tracking-widest text-sm hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 group"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
ดำเนินการชำระเงิน
|
||||
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
import '../lib/i18n';
|
||||
import CartDrawer from './CartDrawer';
|
||||
import ScrollToTop from './ScrollToTop';
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export default function ClientProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<ScrollToTop />
|
||||
{children}
|
||||
<CartDrawer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Category } from '../types'
|
||||
"use client";
|
||||
import Link from 'next/link'
|
||||
import type { Category } from '@homaroma/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface FooterProps {
|
||||
@@ -30,12 +31,12 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
<Link to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
|
||||
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
|
||||
<Link to="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
|
||||
<Link to="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
|
||||
<Link to="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
|
||||
<Link to="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
|
||||
<Link href="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
|
||||
<Link href="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link>
|
||||
<Link href="/scent-guide" className="hover:text-white transition-colors">{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className="hover:text-white transition-colors">{t('nav.about')}</Link>
|
||||
<Link href="/contact" className="hover:text-white transition-colors">{t('nav.contact')}</Link>
|
||||
<Link href="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,7 +44,7 @@ export default function Footer({ categories = [] }: FooterProps) {
|
||||
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('nav.collections')}</h4>
|
||||
<div className="flex flex-col space-y-2 text-sm text-slate-400">
|
||||
{categories.map((c) => (
|
||||
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors">
|
||||
<Link key={c.id} href={`/category/${c.id}`} className="hover:text-white transition-colors">
|
||||
{c.name}
|
||||
</Link>
|
||||
))}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Link, NavLink, useNavigate } from 'react-router-dom'
|
||||
"use client";
|
||||
import Link from 'next/link'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { useState, useEffect } from 'react'
|
||||
import type { Category } from '../types'
|
||||
import type { Category } from '@homaroma/types'
|
||||
import { ShoppingBag, MapPin } from 'lucide-react'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { logoutLiff, loginLiff } from '../lib/liff'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
interface NavbarProps {
|
||||
@@ -17,11 +17,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const [localOrdersCount, setLocalOrdersCount] = useState(0)
|
||||
const navigate = useNavigate()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const { toggleCart, getCartCount } = useCartStore()
|
||||
const cartCount = getCartCount()
|
||||
const { customer } = useAuthStore()
|
||||
const { t, i18n } = useTranslation()
|
||||
|
||||
const toggleLanguage = () => {
|
||||
@@ -45,7 +45,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
}, [])
|
||||
|
||||
// Close mobile menu on route change
|
||||
useEffect(() => { setMobileOpen(false) }, [navigate])
|
||||
useEffect(() => { setMobileOpen(false) }, [pathname])
|
||||
// Prevent body scroll when overlays open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
|
||||
@@ -55,15 +55,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (searchQuery.trim()) {
|
||||
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
|
||||
setSearchOpen(false)
|
||||
setMobileOpen(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
}
|
||||
|
||||
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
isActive ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
|
||||
const navLinkClass = (path: string) =>
|
||||
pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -74,15 +74,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
}`}
|
||||
>
|
||||
{/* Logo */}
|
||||
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0">
|
||||
iRelax <span className="hidden md:inline text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60 ml-1">Essential</span>
|
||||
<Link href="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0">
|
||||
iRelax <span className="text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60">Essential</span>
|
||||
</Link>
|
||||
|
||||
{/* ── DESKTOP NAV ── */}
|
||||
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
|
||||
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
|
||||
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
|
||||
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
|
||||
<Link href="/" className={navLinkClass('/')}>{t('nav.home')}</Link>
|
||||
<Link href="/shop" className={navLinkClass('/shop')}>{t('nav.shop')}</Link>
|
||||
<Link href="/coupons" className={navLinkClass('/coupons')}>🔥 {t('nav.promotions') || 'Promotions'}</Link>
|
||||
|
||||
{/* Collections dropdown */}
|
||||
{categories.length > 0 && (
|
||||
@@ -98,7 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/category/${cat.id}`}
|
||||
href={`/category/${cat.id}`}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-xs uppercase tracking-widest rounded-lg hover:bg-white/10 hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
{cat.name}
|
||||
@@ -112,14 +112,14 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
|
||||
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
|
||||
<Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link>
|
||||
<Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link>
|
||||
</div>
|
||||
|
||||
{/* ── DESKTOP RIGHT ACTIONS ── */}
|
||||
<div className="hidden md:flex items-center gap-3">
|
||||
<Link
|
||||
to="/track"
|
||||
href="/track"
|
||||
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
|
||||
aria-label="Track Orders"
|
||||
title="Track Orders"
|
||||
@@ -153,7 +153,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
)}
|
||||
</button>
|
||||
<Link
|
||||
to="/shop"
|
||||
href="/shop"
|
||||
className="glass px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest hover:bg-cyan-glow hover:text-night-900 transition-all duration-300"
|
||||
>
|
||||
{t('nav.shopNow')}
|
||||
@@ -165,91 +165,39 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
>
|
||||
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
|
||||
</button>
|
||||
|
||||
{customer ? (
|
||||
<div className="relative group flex items-center">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
className="w-9 h-9 rounded-full border-2 border-cyan-glow/50 cursor-pointer object-cover"
|
||||
/>
|
||||
<div className="absolute top-full right-0 mt-4 w-32 bg-night-900/95 backdrop-blur-xl border border-white/10 rounded-xl overflow-hidden opacity-0 pointer-events-none group-hover:opacity-100 group-hover:pointer-events-auto transition-all duration-300 shadow-2xl">
|
||||
<div className="p-1">
|
||||
<div className="px-3 py-2 border-b border-white/10 mb-1">
|
||||
<p className="text-[10px] text-slate-400 uppercase tracking-widest leading-tight truncate">Signed in as</p>
|
||||
<p className="text-xs text-white font-bold truncate">{customer.displayName}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={logoutLiff}
|
||||
className="w-full text-left px-3 py-2 text-xs uppercase tracking-widest text-rose-400 hover:text-rose-300 hover:bg-rose-500/10 rounded-lg transition-colors"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="glass px-4 py-2 rounded-full text-xs font-bold uppercase tracking-widest hover:bg-[#00B900] hover:text-white transition-all duration-300 border border-[#00B900]/50 text-[#00B900]"
|
||||
>
|
||||
LINE Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── MOBILE RIGHT ACTIONS ── */}
|
||||
<div className="flex md:hidden items-center gap-1.5">
|
||||
{customer ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={customer.pictureUrl || `https://ui-avatars.com/api/?name=${customer.displayName}`}
|
||||
alt="Profile"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="w-7 h-7 rounded-full border-2 border-cyan-glow/50 object-cover cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={loginLiff}
|
||||
className="p-1.5 rounded-full glass hover:bg-[#00B900]/20 transition-all border border-[#00B900]/50 text-[#00B900]"
|
||||
aria-label="LINE Login"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M24 10.304c0-5.369-5.383-9.738-12-9.738-6.616 0-12 4.369-12 9.738 0 4.814 3.932 8.946 9.539 9.615.375.056 1.155.176 1.321.603.149.387.049.991.023 1.258-.035.342-.165 1.011 1.05 1.503 1.216.492 4.417-2.602 6.012-4.423C21.841 16.208 24 13.524 24 10.304z"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="flex md:hidden items-center gap-2">
|
||||
<Link
|
||||
to="/track"
|
||||
href="/track"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="relative p-1.5 rounded-full glass hover:bg-white/10 transition-all"
|
||||
className="relative p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Track Orders"
|
||||
>
|
||||
<MapPin className="w-4 h-4" />
|
||||
{localOrdersCount > 0 && (
|
||||
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full" />
|
||||
<span className="absolute top-1 right-1 w-2 h-2 bg-rose-500 rounded-full" />
|
||||
)}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setMobileOpen(false) }}
|
||||
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
|
||||
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Search"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => { toggleCart(true); setMobileOpen(false) }}
|
||||
className="relative p-1.5 rounded-full glass hover:bg-white/10 transition-all"
|
||||
className="relative p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label="Cart"
|
||||
>
|
||||
<ShoppingBag className="w-4 h-4" />
|
||||
<ShoppingBag className="w-5 h-5" />
|
||||
{cartCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center">
|
||||
<span className="absolute top-0 right-0 bg-cyan-glow text-night-900 text-[9px] font-bold w-4 h-4 rounded-full flex items-center justify-center translate-x-1/4 -translate-y-1/4">
|
||||
{cartCount}
|
||||
</span>
|
||||
)}
|
||||
@@ -257,13 +205,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{/* Hamburger */}
|
||||
<button
|
||||
onClick={() => setMobileOpen(prev => !prev)}
|
||||
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
|
||||
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
|
||||
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
|
||||
>
|
||||
<div className="w-4 h-4 flex flex-col justify-center gap-[4px] relative">
|
||||
<span className={`block w-4 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'opacity-0 w-0' : 'w-3'}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute -rotate-45 w-4' : 'w-4'}`} />
|
||||
<div className="w-5 h-5 flex flex-col justify-center gap-[5px] relative">
|
||||
<span className={`block w-5 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'opacity-0 w-0' : 'w-4'}`} />
|
||||
<span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute -rotate-45 w-5' : 'w-5'}`} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
@@ -286,9 +234,6 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-white/5">
|
||||
<span className="text-sm font-bold uppercase tracking-widest text-slate-300">{t('nav.menu')}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
{customer && (
|
||||
<img src={customer.pictureUrl || ''} className="w-6 h-6 rounded-full border border-cyan-glow/50" />
|
||||
)}
|
||||
<button onClick={toggleLanguage} className="glass px-2.5 py-1 rounded-md text-[10px] font-bold text-cyan-glow border border-cyan-glow/20 uppercase">
|
||||
{i18n.language.startsWith('en') ? 'EN' : 'TH'}
|
||||
</button>
|
||||
@@ -303,7 +248,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{/* Drawer Nav Links */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1">
|
||||
{[
|
||||
{ to: '/', label: t('nav.home'), end: true },
|
||||
{ to: '/', label: t('nav.home') },
|
||||
{ to: '/shop', label: t('nav.shop') },
|
||||
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
|
||||
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
|
||||
@@ -311,19 +256,18 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{ to: '/faq', label: t('nav.faq') },
|
||||
{ to: '/contact', label: t('nav.contact') },
|
||||
].map(item => (
|
||||
<NavLink
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
end={item.end}
|
||||
href={item.to}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={({ isActive }) =>
|
||||
className={
|
||||
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${
|
||||
isActive ? 'bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20' : 'hover:bg-white/5 text-slate-300 hover:text-white'
|
||||
pathname === item.to ? 'bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20' : 'hover:bg-white/5 text-slate-300 hover:text-white'
|
||||
}`
|
||||
}
|
||||
>
|
||||
{item.label}
|
||||
</NavLink>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{/* Collections sub-section */}
|
||||
@@ -333,7 +277,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{categories.map(cat => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/category/${cat.id}`}
|
||||
href={`/category/${cat.id}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="flex items-center justify-between px-4 py-2.5 rounded-xl text-sm text-slate-400 hover:text-white hover:bg-white/5 transition-all"
|
||||
>
|
||||
@@ -350,35 +294,19 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
{/* Drawer Footer CTA */}
|
||||
<div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3">
|
||||
<Link
|
||||
to="/shop"
|
||||
href="/shop"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block w-full py-3 bg-white text-night-900 font-bold text-center rounded-xl text-xs uppercase tracking-widest hover:bg-cyan-glow transition-colors duration-300"
|
||||
>
|
||||
{t('nav.shopNow')}
|
||||
</Link>
|
||||
<Link
|
||||
to="/scent-guide"
|
||||
href="/scent-guide"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest hover:bg-white/10 transition-colors border border-white/10"
|
||||
>
|
||||
{t('nav.findMyScent')}
|
||||
</Link>
|
||||
|
||||
{customer ? (
|
||||
<button
|
||||
onClick={() => { logoutLiff(); setMobileOpen(false); }}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-rose-400 hover:bg-rose-500/10 transition-colors border border-rose-500/20 mt-2"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => { loginLiff(); setMobileOpen(false); }}
|
||||
className="block w-full py-3 glass text-center rounded-xl text-xs font-bold uppercase tracking-widest text-[#00B900] hover:bg-[#00B900]/10 transition-colors border border-[#00B900]/50 mt-2"
|
||||
>
|
||||
Login with LINE
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -428,7 +356,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
|
||||
].map(item => (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
href={item.to}
|
||||
onClick={() => setSearchOpen(false)}
|
||||
className="glass px-4 py-1.5 rounded-full text-xs font-medium hover:bg-white/10 hover:text-cyan-glow transition-all border border-white/5"
|
||||
>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { Product } from '../types'
|
||||
import { getCategoryImage, formatPrice } from '../types'
|
||||
import Link from 'next/link'
|
||||
import type { Product } from '@homaroma/types'
|
||||
import { getCategoryImage, formatPrice } from '@homaroma/types'
|
||||
|
||||
interface ProductCardProps {
|
||||
product: Product
|
||||
@@ -11,7 +11,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
<div className="glass glass-hover rounded-2xl overflow-hidden flex flex-col group p-1.5 md:p-2">
|
||||
<div className="h-40 md:h-64 overflow-hidden rounded-xl relative">
|
||||
<img
|
||||
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
|
||||
src={product.images?.[0]?.url || product.category.imageUrl || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
/>
|
||||
@@ -52,7 +52,7 @@ export default function ProductCard({ product }: ProductCardProps) {
|
||||
<div className="flex-1" />
|
||||
|
||||
<Link
|
||||
to={`/products/${product.id}`}
|
||||
href={`/products/${product.id}`}
|
||||
className="block w-full py-2 md:py-3 text-center bg-white/10 hover:bg-white text-white hover:text-night-900 rounded-lg font-bold uppercase tracking-widest text-[10px] md:text-xs transition-all duration-300"
|
||||
>
|
||||
Discover
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
import { useEffect } from 'react'
|
||||
import { useLocation } from 'react-router-dom'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Scrolls window to top on every route change.
|
||||
* Place this inside <BrowserRouter> so it has access to location context.
|
||||
* Scrolls window to top on every route change (Next.js version).
|
||||
*/
|
||||
export default function ScrollToTop() {
|
||||
const { pathname } = useLocation()
|
||||
const pathname = usePathname()
|
||||
|
||||
useEffect(() => {
|
||||
window.scrollTo({ top: 0, behavior: 'instant' })
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enTranslation from './locales/en/translation.json'
|
||||
import thTranslation from './locales/th/translation.json'
|
||||
|
||||
// Retrieve default lang from localStorage if set
|
||||
const savedLang = localStorage.getItem('irelax_lang') || 'th'
|
||||
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: enTranslation },
|
||||
th: { translation: thTranslation },
|
||||
},
|
||||
lng: savedLang, // default language (Thai)
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
})
|
||||
|
||||
export default i18n
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Central API base URL resolver.
|
||||
*
|
||||
* Priority:
|
||||
* 1. NEXT_PUBLIC_API_URL — ตั้งค่าใน .env / .env.production (Client + Server)
|
||||
* 2. INTERNAL_API_URL — ตั้งค่าใน .env สำหรับ Server-side เท่านั้น
|
||||
* ใช้เมื่อ Server fetch ภายใน network เดียวกัน
|
||||
* 3. Fallback dev — http://localhost:3000/api
|
||||
*
|
||||
* Dev: NEXT_PUBLIC_API_URL=http://localhost:3000/api
|
||||
* Production: NEXT_PUBLIC_API_URL=https://api.irelaxaroma.shop/api
|
||||
*/
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_URL ||
|
||||
process.env.INTERNAL_API_URL ||
|
||||
'http://localhost:3000/api'
|
||||
@@ -0,0 +1,27 @@
|
||||
import i18n from 'i18next'
|
||||
import { initReactI18next } from 'react-i18next'
|
||||
|
||||
import enTranslation from '../locales/en/translation.json'
|
||||
import thTranslation from '../locales/th/translation.json'
|
||||
|
||||
// Ensure we only access localStorage on the client side
|
||||
const savedLang = typeof window !== 'undefined' ? (localStorage.getItem('irelax_lang') || 'th') : 'th'
|
||||
|
||||
// Check if it's already initialized to prevent re-initialization on HMR
|
||||
if (!i18n.isInitialized) {
|
||||
i18n
|
||||
.use(initReactI18next)
|
||||
.init({
|
||||
resources: {
|
||||
en: { translation: enTranslation },
|
||||
th: { translation: thTranslation },
|
||||
},
|
||||
lng: savedLang, // default language
|
||||
fallbackLng: 'en',
|
||||
interpolation: {
|
||||
escapeValue: false, // react already safes from xss
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default i18n
|
||||
@@ -1,63 +0,0 @@
|
||||
import liff from "@line/liff";
|
||||
import { useAuthStore } from "../store/authStore";
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000/api";
|
||||
|
||||
export const initLiff = async (liffId: string) => {
|
||||
try {
|
||||
await liff.init({ liffId });
|
||||
useAuthStore.getState().setLiffInitialized(true);
|
||||
|
||||
if (liff.isLoggedIn()) {
|
||||
const profile = await liff.getProfile();
|
||||
await authenticateWithBackend(profile);
|
||||
} else if (liff.isInClient()) {
|
||||
// เปิดเว็บผ่านเบราว์เซอร์ของ LINE (In-App Browser) แต่ยังไม่ได้ล็อกอิน
|
||||
// ส่ง redirectUri: window.location.href เพื่อให้หลัง login แล้วกลับมาที่หน้าเดิม
|
||||
liff.login({ redirectUri: window.location.href });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("LIFF Initialization failed", error);
|
||||
}
|
||||
};
|
||||
|
||||
const authenticateWithBackend = async (profile: any) => {
|
||||
try {
|
||||
const response = await fetch(`${API_URL}/auth/line`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
lineUserId: profile.userId,
|
||||
displayName: profile.displayName,
|
||||
pictureUrl: profile.pictureUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.success && data.token) {
|
||||
useAuthStore.getState().setAuth(data.token, data.customer);
|
||||
}
|
||||
} else {
|
||||
console.error("Backend authentication failed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error authenticating with backend", error);
|
||||
}
|
||||
};
|
||||
|
||||
export const loginLiff = () => {
|
||||
if (!liff.isLoggedIn()) {
|
||||
// ส่ง redirectUri เพื่อกลับมาที่หน้าเดิมหลัง login
|
||||
liff.login({ redirectUri: window.location.href });
|
||||
}
|
||||
};
|
||||
|
||||
export const logoutLiff = () => {
|
||||
if (liff.isLoggedIn()) {
|
||||
liff.logout();
|
||||
useAuthStore.getState().logout();
|
||||
}
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import './i18n'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -1,85 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { API_BASE, type Article, type Category } from '../types'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { ArrowLeft, Loader2 } from 'lucide-react'
|
||||
|
||||
export default function ArticleReaderPage() {
|
||||
const { slug } = useParams()
|
||||
const [article, setArticle] = useState<Article | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
fetch(`${API_BASE}/articles/${slug}`)
|
||||
.then(res => {
|
||||
if (!res.ok) throw new Error()
|
||||
return res.json()
|
||||
})
|
||||
.then(setArticle)
|
||||
.catch(() => setArticle(null))
|
||||
.finally(() => setLoading(false))
|
||||
}, [slug])
|
||||
|
||||
if (loading) return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-black">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-cyan-glow" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (!article) return (
|
||||
<div className="min-h-screen flex flex-col bg-black">
|
||||
<Navbar categories={categories} />
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-6 text-center space-y-4">
|
||||
<h1 className="text-4xl font-bold">Article not found</h1>
|
||||
<Link to="/scent-guide" className="btn-secondary px-6 py-3">Return to Scent Guide</Link>
|
||||
</div>
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-night-950">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="flex-1 pt-24 pb-20">
|
||||
<article className="max-w-4xl mx-auto w-full animate-fade-in relative mt-8">
|
||||
|
||||
<Link to="/scent-guide" className="absolute -left-16 xl:-left-24 top-0 mt-8 hidden lg:flex p-4 glass border border-white/5 rounded-full hover:bg-white/10 transition-colors">
|
||||
<ArrowLeft className="w-5 h-5 text-slate-300" />
|
||||
</Link>
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center px-6 py-12 space-y-6">
|
||||
<Link to="/scent-guide" className="lg:hidden text-slate-400 text-sm hover:text-white inline-flex items-center gap-2 mb-4">
|
||||
<ArrowLeft className="w-4 h-4" /> Go Back
|
||||
</Link>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] font-bold text-xs">
|
||||
{new Date(article.createdAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
<h1 className="text-4xl md:text-5xl lg:text-6xl font-bold leading-tight max-w-3xl mx-auto font-serif">
|
||||
{article.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Cover Image */}
|
||||
{article.coverImage && (
|
||||
<div className="w-full aspect-[16/7] bg-night-900 md:rounded-[2rem] overflow-hidden shadow-2xl mb-16 border border-white/5">
|
||||
<img src={article.coverImage} alt={article.title} className="w-full h-full object-cover" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="px-6 md:px-12 prose prose-invert prose-lg prose-cyan mx-auto max-w-3xl pb-16">
|
||||
<div dangerouslySetInnerHTML={{ __html: article.content }} className="space-y-6" />
|
||||
</div>
|
||||
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, formatPrice, type Category } from '../types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
export default function CheckoutPage() {
|
||||
const navigate = useNavigate()
|
||||
const { items, getCartTotal, clearCart } = useCartStore()
|
||||
const { customer } = useAuthStore()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
const [shippingFee, setShippingFee] = useState<number>(35)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingSettings, setLoadingSettings] = useState(true)
|
||||
|
||||
const [couponCode, setCouponCode] = useState('')
|
||||
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null)
|
||||
const [discountAmount, setDiscountAmount] = useState<number>(0)
|
||||
const [isFreeShipping, setIsFreeShipping] = useState<boolean>(false)
|
||||
const [couponLoading, setCouponLoading] = useState(false)
|
||||
const [couponError, setCouponError] = useState('')
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
address: ''
|
||||
})
|
||||
|
||||
// Prefill form with LINE customer data
|
||||
useEffect(() => {
|
||||
if (customer) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
name: prev.name || customer.displayName || '',
|
||||
email: prev.email || customer.email || '',
|
||||
phone: prev.phone || customer.phone || ''
|
||||
}))
|
||||
}
|
||||
}, [customer])
|
||||
|
||||
// Load Settings (for dynamic shipping fee)
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/settings`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
if (data.shippingFee !== undefined) setShippingFee(data.shippingFee)
|
||||
})
|
||||
.catch(err => console.error("Could not load settings:", err))
|
||||
.finally(() => setLoadingSettings(false))
|
||||
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
|
||||
// Check if there's an active coupon saved in localStorage
|
||||
const savedCoupon = localStorage.getItem('irelax_active_coupon')
|
||||
if (savedCoupon) {
|
||||
setCouponCode(savedCoupon)
|
||||
// We will trigger handleApplyCoupon but we need to wait for items/subtotal to be ready.
|
||||
// So we'll handle this in another effect or let the user click apply if needed.
|
||||
// Better yet, we can validate it immediately if subtotal is available.
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Kick out if cart empty
|
||||
useEffect(() => {
|
||||
if (items.length === 0) {
|
||||
navigate('/shop')
|
||||
}
|
||||
}, [items, navigate])
|
||||
|
||||
const subtotal = getCartTotal()
|
||||
const effectiveShippingFee = isFreeShipping ? 0 : shippingFee
|
||||
const total = Math.max(0, subtotal + effectiveShippingFee - discountAmount)
|
||||
|
||||
// Auto-apply saved coupon if subtotal is ready
|
||||
useEffect(() => {
|
||||
const savedCoupon = localStorage.getItem('irelax_active_coupon')
|
||||
if (savedCoupon && subtotal > 0 && !appliedCoupon && !couponError && !couponLoading) {
|
||||
setCouponCode(savedCoupon)
|
||||
// trigger fetch to validate
|
||||
fetch(`${API_BASE}/coupons/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: savedCoupon, cartTotal: subtotal })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.valid) {
|
||||
setAppliedCoupon(data.coupon.code)
|
||||
setDiscountAmount(data.discountAmount)
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
localStorage.removeItem('irelax_active_coupon') // Remove invalid
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
}
|
||||
}, [subtotal, appliedCoupon])
|
||||
|
||||
// Re-validate applied coupon if subtotal changes
|
||||
useEffect(() => {
|
||||
if (appliedCoupon && subtotal > 0) {
|
||||
fetch(`${API_BASE}/coupons/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: appliedCoupon, cartTotal: subtotal })
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.valid) {
|
||||
setDiscountAmount(data.discountAmount)
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
removeCoupon()
|
||||
setCouponError(data.error || 'Coupon is no longer valid for this cart')
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [subtotal, appliedCoupon])
|
||||
|
||||
const handleApplyCoupon = async () => {
|
||||
if (!couponCode.trim()) return
|
||||
setCouponLoading(true)
|
||||
setCouponError('')
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/coupons/validate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: couponCode, cartTotal: subtotal })
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.valid) {
|
||||
setAppliedCoupon(data.coupon.code)
|
||||
setDiscountAmount(data.discountAmount)
|
||||
setIsFreeShipping(data.isFreeShipping)
|
||||
} else {
|
||||
setCouponError(data.error || 'Invalid coupon')
|
||||
setAppliedCoupon(null)
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
}
|
||||
} catch (err) {
|
||||
setCouponError('Error validating coupon')
|
||||
} finally {
|
||||
setCouponLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const removeCoupon = () => {
|
||||
setAppliedCoupon(null)
|
||||
setCouponCode('')
|
||||
setDiscountAmount(0)
|
||||
setIsFreeShipping(false)
|
||||
setCouponError('')
|
||||
localStorage.removeItem('irelax_active_coupon')
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const orderData = {
|
||||
customerId: customer ? customer.id : null,
|
||||
customerName: formData.name,
|
||||
customerEmail: formData.email,
|
||||
customerPhone: formData.phone,
|
||||
shippingAddress: formData.address,
|
||||
totalAmount: total,
|
||||
shippingFee: effectiveShippingFee,
|
||||
couponCode: appliedCoupon || undefined,
|
||||
discountAmount: discountAmount || undefined,
|
||||
items: items.map(item => ({
|
||||
productId: item.productId,
|
||||
variantId: item.variantId,
|
||||
quantity: item.quantity,
|
||||
priceAtPurchase: item.price,
|
||||
productName: item.productName,
|
||||
variantSize: item.variantSize,
|
||||
productImage: item.productImage
|
||||
}))
|
||||
}
|
||||
|
||||
const res = await fetch(`${API_BASE}/orders`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(orderData)
|
||||
})
|
||||
|
||||
const responseData = await res.json()
|
||||
|
||||
if (res.ok) {
|
||||
clearCart()
|
||||
|
||||
const existingStr = localStorage.getItem('irelax_local_orders')
|
||||
const existing = existingStr ? JSON.parse(existingStr) : []
|
||||
if (!existing.includes(responseData.id)) {
|
||||
localStorage.setItem('irelax_local_orders', JSON.stringify([responseData.id, ...existing]))
|
||||
}
|
||||
|
||||
navigate(`/order/${responseData.id}`)
|
||||
} else {
|
||||
alert(responseData.error || 'Failed to place order')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert('An error occurred. Please try again.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24">
|
||||
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
|
||||
|
||||
<div className="flex flex-col lg:flex-row gap-12">
|
||||
{/* Form Section */}
|
||||
<div className="flex-1 space-y-8">
|
||||
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
|
||||
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Shipping Information</h2>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={formData.name}
|
||||
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
|
||||
placeholder="Jane Doe"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Phone Number *</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
|
||||
placeholder="0812345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Email Address *</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={formData.email}
|
||||
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
|
||||
placeholder="jane@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2 space-y-2">
|
||||
<label className="text-xs uppercase tracking-widest text-slate-400">Shipping Address *</label>
|
||||
<textarea
|
||||
required
|
||||
rows={3}
|
||||
value={formData.address}
|
||||
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all resize-none"
|
||||
placeholder="123 Serenity Street..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Summary Section */}
|
||||
<div className="lg:w-96 space-y-6">
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 sticky top-32">
|
||||
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4 mb-6">Order Summary</h2>
|
||||
|
||||
{/* Coupon Section */}
|
||||
<div className="mb-6 pb-6 border-b border-white/10">
|
||||
{!appliedCoupon ? (
|
||||
<div className="space-y-2">
|
||||
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Discount Code</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={couponCode}
|
||||
onChange={e => setCouponCode(e.target.value.toUpperCase())}
|
||||
placeholder="e.g. SUMMER2026"
|
||||
className="flex-1 bg-night-950 border border-white/10 rounded-lg px-3 text-sm text-white focus:border-cyan-glow outline-none uppercase tracking-widest"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApplyCoupon}
|
||||
disabled={couponLoading || !couponCode.trim()}
|
||||
className="px-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all"
|
||||
>
|
||||
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
|
||||
</button>
|
||||
</div>
|
||||
{couponError && <p className="text-xs text-rose-500 mt-1">{couponError}</p>}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<svg className="w-4 h-4 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm font-bold tracking-widest text-cyan-glow">{appliedCoupon}</span>
|
||||
</div>
|
||||
<button type="button" onClick={removeCoupon} className="text-xs text-slate-400 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 mb-6">
|
||||
{items.map(item => (
|
||||
<div key={item.cartItemId} className="flex justify-between items-start gap-4 text-sm">
|
||||
<div className="flex-1">
|
||||
<p className="font-bold">{item.productName}</p>
|
||||
<p className="text-slate-400 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
|
||||
</div>
|
||||
<p className="font-medium text-cyan-glow">{formatPrice(item.price * item.quantity)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-white/10 pt-4 space-y-3 font-medium">
|
||||
<div className="flex justify-between text-sm text-slate-300">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(subtotal)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-slate-300">
|
||||
<span>Shipping Fee</span>
|
||||
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
||||
<span className={isFreeShipping ? "line-through text-slate-500" : ""}>{formatPrice(shippingFee)}</span>
|
||||
}
|
||||
</div>
|
||||
{isFreeShipping && (
|
||||
<div className="flex justify-between text-sm text-emerald-400">
|
||||
<span>Shipping Discount</span>
|
||||
<span>- {formatPrice(shippingFee)}</span>
|
||||
</div>
|
||||
)}
|
||||
{discountAmount > 0 && (
|
||||
<div className="flex justify-between text-sm text-cyan-glow">
|
||||
<span>Discount</span>
|
||||
<span>- {formatPrice(discountAmount)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-white/10 pt-3 mt-3 flex justify-between text-lg font-bold">
|
||||
<span>Total</span>
|
||||
<span className="text-cyan-glow">{formatPrice(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
form="checkout-form"
|
||||
type="submit"
|
||||
disabled={loading || loadingSettings}
|
||||
className="w-full mt-8 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm Order'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
import { Ticket, Copy, CheckCircle2, ArrowRight } from 'lucide-react'
|
||||
|
||||
interface Coupon {
|
||||
id: number
|
||||
code: string
|
||||
type: string
|
||||
value: number
|
||||
minPurchase: number
|
||||
usageLimit: number | null
|
||||
usedCount: number
|
||||
expiresAt: string | null
|
||||
}
|
||||
|
||||
export default function CouponsPage() {
|
||||
const navigate = useNavigate()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [coupons, setCoupons] = useState<Coupon[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
|
||||
fetch(`${API_BASE}/coupons/active`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
// Filter out coupons that have reached their limit
|
||||
const availableCoupons = data.filter((c: Coupon) =>
|
||||
c.usageLimit === null || c.usedCount < c.usageLimit
|
||||
)
|
||||
setCoupons(availableCoupons)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
const formatDiscount = (type: string, value: number) => {
|
||||
if (type === 'PERCENTAGE') return `${value}% OFF`
|
||||
if (type === 'FIXED_AMOUNT') return `ลด ${value} ฿`
|
||||
if (type === 'FREE_SHIPPING') return `ส่งฟรี`
|
||||
return `${value}`
|
||||
}
|
||||
|
||||
const handleCopy = (code: string) => {
|
||||
navigator.clipboard.writeText(code)
|
||||
setCopiedCode(code)
|
||||
setTimeout(() => setCopiedCode(null), 2000)
|
||||
}
|
||||
|
||||
const handleUseNow = (code: string) => {
|
||||
localStorage.setItem('irelax_active_coupon', code)
|
||||
navigate('/shop')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 min-h-[70vh]">
|
||||
<div className="text-center space-y-4 mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold bg-gradient-to-r from-white via-cyan-glow to-blue-500 bg-clip-text text-transparent">
|
||||
Exclusive Offers
|
||||
</h1>
|
||||
<p className="text-slate-400 max-w-2xl mx-auto">
|
||||
คูปองส่วนลดพิเศษสำหรับคุณ เลือกใช้คูปองที่คุณต้องการแล้วไปช้อปกันเลย!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{[1, 2, 3].map(i => (
|
||||
<div key={i} className="h-64 bg-white/5 rounded-3xl animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : coupons.length === 0 ? (
|
||||
<div className="text-center py-20 bg-night-900/50 rounded-3xl border border-white/5">
|
||||
<Ticket className="w-16 h-16 text-slate-600 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-white mb-2">ยังไม่มีคูปองส่วนลดในขณะนี้</h2>
|
||||
<p className="text-slate-400 mb-6">กรุณากลับมาตรวจสอบโปรโมชั่นใหม่ในภายหลัง</p>
|
||||
<button
|
||||
onClick={() => navigate('/shop')}
|
||||
className="px-8 py-3 bg-cyan-glow/10 text-cyan-glow hover:bg-cyan-glow hover:text-night-900 font-bold rounded-xl transition-all"
|
||||
>
|
||||
ไปช้อปปิ้งกันก่อน
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{coupons.map(coupon => (
|
||||
<div
|
||||
key={coupon.id}
|
||||
className="group relative bg-night-900 border border-white/10 rounded-3xl overflow-hidden hover:border-cyan-glow/50 transition-all duration-500"
|
||||
>
|
||||
{/* Decoration */}
|
||||
<div className="absolute top-0 right-0 w-32 h-32 bg-cyan-glow/10 blur-3xl rounded-full -translate-y-1/2 translate-x-1/2" />
|
||||
|
||||
<div className="p-8 space-y-6 relative z-10">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="w-12 h-12 rounded-xl bg-cyan-glow/10 text-cyan-glow flex items-center justify-center border border-cyan-glow/20">
|
||||
<Ticket className="w-6 h-6" />
|
||||
</div>
|
||||
{coupon.expiresAt && (
|
||||
<span className="text-[10px] font-bold text-amber-400 bg-amber-500/10 px-2 py-1 rounded-md uppercase tracking-wider">
|
||||
หมดเขต {new Date(coupon.expiresAt).toLocaleDateString('th-TH')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-3xl font-black text-white mb-1">
|
||||
{formatDiscount(coupon.type, coupon.value)}
|
||||
</h3>
|
||||
<p className="text-sm text-slate-400">
|
||||
เมื่อซื้อครบ {coupon.minPurchase} บาท
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 bg-night-950 p-1 pl-4 rounded-xl border border-white/5">
|
||||
<span className="flex-1 font-mono text-cyan-glow font-bold tracking-widest uppercase">
|
||||
{coupon.code}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => handleCopy(coupon.code)}
|
||||
className="p-3 hover:bg-white/5 rounded-lg text-slate-400 hover:text-white transition-all group/btn"
|
||||
title="Copy Code"
|
||||
>
|
||||
{copiedCode === coupon.code ? (
|
||||
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||
) : (
|
||||
<Copy className="w-5 h-5 group-hover/btn:scale-110 transition-transform" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleUseNow(coupon.code)}
|
||||
className="w-full py-4 bg-white/5 hover:bg-cyan-glow text-white hover:text-night-900 font-bold rounded-xl flex items-center justify-center gap-2 transition-all duration-300"
|
||||
>
|
||||
ใช้คูปองนี้เลย
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scalloped edge effect */}
|
||||
<div className="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-r border-white/10" />
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 translate-x-1/2 w-6 h-6 bg-[#0a0a0a] rounded-full border-l border-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center px-6 bg-night-900">
|
||||
{/* Background glow */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-cyan-glow/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-night-700/10 rounded-full blur-3xl" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10 text-center space-y-8 max-w-lg">
|
||||
{/* 404 Large */}
|
||||
<div className="relative">
|
||||
<div className="text-[10rem] font-bold leading-none text-transparent bg-clip-text bg-gradient-to-b from-white/20 to-white/5 select-none">
|
||||
404
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-5xl animate-float">🌿</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h1 className="text-3xl font-bold">Page Not Found</h1>
|
||||
<p className="text-slate-400 leading-relaxed">
|
||||
The page you're looking for seems to have drifted away like morning mist. Let's guide you back to serenity.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center pt-2">
|
||||
<Link
|
||||
to="/"
|
||||
className="px-8 py-3.5 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 text-sm"
|
||||
>
|
||||
Go Home
|
||||
</Link>
|
||||
<Link
|
||||
to="/shop"
|
||||
className="px-8 py-3.5 glass text-white font-bold rounded-lg tracking-widest uppercase hover:bg-white/10 transition-all duration-300 border border-white/10 text-sm"
|
||||
>
|
||||
Browse Products
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-white/5">
|
||||
<div className="flex justify-center gap-6 text-xs text-slate-500">
|
||||
<Link to="/scent-guide" className="hover:text-white transition-colors">Scent Guide</Link>
|
||||
<Link to="/faq" className="hover:text-white transition-colors">FAQ</Link>
|
||||
<Link to="/contact" className="hover:text-white transition-colors">Contact</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,227 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, formatPrice, type Order, type StoreSettings, type Category } from '../types'
|
||||
import { CheckCircle2, UploadCloud, Loader2, Copy } from 'lucide-react'
|
||||
|
||||
export default function OrderSuccessPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [order, setOrder] = useState<Order | null>(null)
|
||||
const [settings, setSettings] = useState<StoreSettings | null>(null)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/orders/${id}`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/settings`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json())
|
||||
])
|
||||
.then(([orderData, settingsData, catData]) => {
|
||||
setOrder(orderData)
|
||||
setSettings(settingsData)
|
||||
setCategories(catData)
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (!settings) return
|
||||
navigator.clipboard.writeText(settings.bankAccountNumber)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !order) return
|
||||
|
||||
setUploading(true)
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
try {
|
||||
// 1. Upload file
|
||||
const uploadRes = await fetch(`${API_BASE}/upload`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
const uploadData = await uploadRes.json()
|
||||
|
||||
if (uploadData.url) {
|
||||
// 2. Update order slip
|
||||
const updateRes = await fetch(`${API_BASE}/orders/${order.id}/slip`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paymentSlipUrl: uploadData.url })
|
||||
})
|
||||
const updatedOrder = await updateRes.json()
|
||||
setOrder(updatedOrder)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
alert("Failed to upload payment slip.")
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
<div className="flex items-center justify-center pt-40">
|
||||
<Loader2 className="w-8 h-8 text-cyan-glow animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!order || (order as any).error) {
|
||||
return (
|
||||
<div className="min-h-screen text-center pt-40">
|
||||
<Navbar categories={categories} />
|
||||
<h1 className="text-2xl font-bold mb-4">Order Not Found</h1>
|
||||
<Link to="/shop" className="text-cyan-glow hover:underline">Return to Shop</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="max-w-3xl mx-auto px-6 pt-32 pb-24 space-y-12 animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-6">
|
||||
<div className="w-20 h-20 bg-cyan-glow/10 rounded-full flex items-center justify-center mx-auto border border-cyan-glow/20">
|
||||
<CheckCircle2 className="w-10 h-10 text-cyan-glow" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold">Order Received</h1>
|
||||
<p className="text-slate-400 mt-2">Thank you! Your order #{order.id.toString().padStart(4, '0')} has been placed.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Order Summary */}
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Order Items</h2>
|
||||
<div className="space-y-4">
|
||||
{order.items.map(item => (
|
||||
<div key={item.id} className="flex items-center gap-4 text-sm">
|
||||
<div className="w-12 h-12 rounded-lg bg-white/5 flex-shrink-0 overflow-hidden">
|
||||
<img src={item.productImage || '/images/oil_ozone.png'} alt="Product" className="w-full h-full object-cover" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-bold">{item.productName}</p>
|
||||
<p className="text-slate-400 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
|
||||
</div>
|
||||
<p className="font-medium text-cyan-glow">{formatPrice(item.priceAtPurchase * item.quantity)}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-4 space-y-3 font-medium text-sm text-slate-300">
|
||||
<div className="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{formatPrice(order.totalAmount - order.shippingFee)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>Shipping Fee</span>
|
||||
<span>{formatPrice(order.shippingFee)}</span>
|
||||
</div>
|
||||
<div className="border-t border-white/10 pt-3 mt-3 flex justify-between text-lg font-bold text-white">
|
||||
<span>Total</span>
|
||||
<span className="text-cyan-glow">{formatPrice(order.totalAmount)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Banking Details */}
|
||||
{settings && order.status === 'PENDING_PAYMENT' && (
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 text-center space-y-6 relative overflow-hidden">
|
||||
<div className="absolute top-0 inset-x-0 h-1 bg-gradient-to-r from-transparent via-cyan-glow to-transparent opacity-50" />
|
||||
|
||||
<h2 className="text-xs uppercase font-bold tracking-widest text-slate-400">Payment Required</h2>
|
||||
<div className="text-3xl font-bold text-cyan-glow">
|
||||
{formatPrice(order.totalAmount)}
|
||||
</div>
|
||||
|
||||
<div className="bg-night-950 p-6 rounded-xl border border-white/5 inline-block text-left w-full max-w-md mx-auto space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">Bank</p>
|
||||
<p className="font-bold">{settings.bankName}</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">Account Number</p>
|
||||
<p className="font-mono text-xl font-bold tracking-widest text-cyan-glow">{settings.bankAccountNumber}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-2 glass rounded-lg hover:bg-white/10 transition-colors shrink-0"
|
||||
title="Copy account number"
|
||||
>
|
||||
{copied ? <CheckCircle2 className="w-5 h-5 text-emerald-400" /> : <Copy className="w-5 h-5 text-slate-300" />}
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest">Account Name</p>
|
||||
<p className="font-bold">{settings.bankAccountName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-slate-500">Please transfer the exact amount and upload your slip below.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload Status */}
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
|
||||
<h2 className="text-sm font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Payment Slip Verification</h2>
|
||||
|
||||
{order.paymentSlipUrl ? (
|
||||
<div className="flex flex-col items-center justify-center space-y-4 py-4">
|
||||
<div className="w-16 h-16 bg-emerald-500/10 rounded-full flex items-center justify-center">
|
||||
<CheckCircle2 className="w-8 h-8 text-emerald-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="font-bold text-lg text-emerald-400">Slip Uploaded Successfully</p>
|
||||
<p className="text-sm text-slate-400 mt-1">Status: {order.status}</p>
|
||||
<p className="text-xs mt-2 text-slate-500">We will verify your payment and process your order shortly.</p>
|
||||
</div>
|
||||
<img src={order.paymentSlipUrl} alt="Slip" className="w-48 rounded-xl border border-white/10 mt-4 opacity-50 hover:opacity-100 transition-opacity" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative border-2 border-dashed border-white/10 rounded-xl p-10 hover:border-cyan-glow/50 transition-colors group cursor-pointer overflow-hidden">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="absolute inset-0 w-full h-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<div className="text-center space-y-4">
|
||||
{uploading ? (
|
||||
<Loader2 className="w-12 h-12 text-cyan-glow animate-spin mx-auto" />
|
||||
) : (
|
||||
<UploadCloud className="w-12 h-12 text-slate-500 group-hover:text-cyan-glow transition-colors mx-auto" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-bold">{uploading ? 'Uploading...' : 'Click or drag to upload slip'}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">Supports JPG, PNG (Max 5MB)</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { API_BASE, getCategoryImage, formatPrice, type Category, type Product } from '../types'
|
||||
import { useCartStore } from '../store/cartStore'
|
||||
|
||||
export default function ProductDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [product, setProduct] = useState<Product | null>(null)
|
||||
const [related, setRelated] = useState<Product[]>([])
|
||||
const [selectedVariant, setSelectedVariant] = useState<number>(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const { addToCart } = useCartStore()
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then((r) => r.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
setSelectedVariant(0)
|
||||
|
||||
fetch(`${API_BASE}/products/${id}`)
|
||||
.then(async (r) => {
|
||||
if (!r.ok) throw new Error('Product not found')
|
||||
return r.json()
|
||||
})
|
||||
.then(async (data: Product) => {
|
||||
setProduct(data)
|
||||
// Fetch related (same category, different product)
|
||||
const relRes = await fetch(`${API_BASE}/products?categoryId=${data.category.id}`)
|
||||
const relJson = await relRes.json()
|
||||
const relData: Product[] = relJson.data || []
|
||||
setRelated(relData.filter((p) => p.id !== data.id).slice(0, 6))
|
||||
})
|
||||
.catch((err) => setError(err.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
<div className="pt-32 px-6 max-w-7xl mx-auto animate-pulse space-y-8">
|
||||
<div className="h-6 w-48 bg-white/5 rounded" />
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
<div className="h-96 bg-white/5 rounded-2xl" />
|
||||
<div className="space-y-4">
|
||||
<div className="h-8 w-3/4 bg-white/5 rounded" />
|
||||
<div className="h-4 w-1/2 bg-white/5 rounded" />
|
||||
<div className="h-12 w-1/3 bg-white/5 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !product) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
<div className="pt-40 text-center space-y-4">
|
||||
<p className="text-4xl">⚠️</p>
|
||||
<p className="text-slate-400">{error ?? 'Product not found'}</p>
|
||||
<Link to="/shop" className="text-cyan-glow hover:underline text-sm">
|
||||
Back to Shop
|
||||
</Link>
|
||||
</div>
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const currentVariant = product.variants[selectedVariant]
|
||||
|
||||
const handleAddToCart = () => {
|
||||
if (!product || !currentVariant) return
|
||||
addToCart({
|
||||
productId: product.id,
|
||||
variantId: currentVariant.id,
|
||||
quantity: 1,
|
||||
price: currentVariant.price || product.price || 0,
|
||||
productName: product.scent.name,
|
||||
variantSize: currentVariant.size,
|
||||
productImage: product.images?.[0]?.url || getCategoryImage(product.category.name)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen pb-24 md:pb-0">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 pt-32 pb-24 space-y-24">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-xs text-slate-400 uppercase tracking-widest">
|
||||
<Link to="/" className="hover:text-white transition-colors">Home</Link>
|
||||
<span>/</span>
|
||||
<Link to="/shop" className="hover:text-white transition-colors">Shop</Link>
|
||||
<span>/</span>
|
||||
<Link to={`/category/${product.category.id}`} className="hover:text-white transition-colors">
|
||||
{product.category.name}
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-cyan-glow">{product.scent.name}</span>
|
||||
</div>
|
||||
|
||||
{/* Product Detail */}
|
||||
<div className="grid md:grid-cols-2 gap-16 items-center">
|
||||
{/* Image */}
|
||||
<div className="relative rounded-2xl overflow-hidden h-[480px] group">
|
||||
<img
|
||||
src={product.images?.[0]?.url || getCategoryImage(product.category.name)}
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
|
||||
alt={product.scent.name}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none' }}
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-night-900/50 to-transparent" />
|
||||
<div className="absolute top-6 left-6 glass px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-widest text-cyan-glow border border-cyan-glow/20">
|
||||
{product.category.name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="space-y-8">
|
||||
<div className="space-y-2">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-80">
|
||||
{product.category.name}
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold leading-tight">
|
||||
{product.scent.name}
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm uppercase tracking-widest">
|
||||
Premium Collection
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{product.description && (
|
||||
<p className="text-slate-300 leading-relaxed">{product.description}</p>
|
||||
)}
|
||||
|
||||
{/* Price */}
|
||||
<div className="flex items-baseline gap-3">
|
||||
<span className="text-4xl font-bold text-cyan-glow">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
</span>
|
||||
{currentVariant && (
|
||||
<span className="text-slate-400 text-sm">/ {currentVariant.size}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Variant Selector */}
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs uppercase tracking-widest text-slate-400 font-semibold">
|
||||
Select Size
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={v.id}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`px-5 py-2.5 rounded-lg text-sm font-bold uppercase tracking-widest transition-all duration-300 ${
|
||||
selectedVariant === i
|
||||
? 'bg-cyan-glow text-night-900 shadow-[0_0_15px_rgba(102,252,241,0.4)]'
|
||||
: 'glass hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{v.size}
|
||||
<span className={`block text-[10px] mt-0.5 ${selectedVariant === i ? 'text-night-800' : 'text-slate-400'}`}>
|
||||
{formatPrice(v.price)}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
<button className="glass p-4 rounded-lg hover:bg-white/10 transition-all">
|
||||
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Meta */}
|
||||
<div className="border-t border-white/5 pt-6 grid grid-cols-2 gap-4 text-sm text-slate-400">
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1">Scent</span>
|
||||
<span className="text-white font-medium">{product.scent.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-xs uppercase tracking-widest block mb-1">Category</span>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-white font-medium hover:text-cyan-glow transition-colors"
|
||||
>
|
||||
{product.category.name}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full Details Section */}
|
||||
{product.details && (
|
||||
<div className="pt-16 border-t border-white/5 space-y-6">
|
||||
<h2 className="text-3xl font-bold">Product Details</h2>
|
||||
<div
|
||||
className="prose prose-invert prose-lg max-w-4xl product-editorial"
|
||||
dangerouslySetInnerHTML={{ __html: product.details }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Related Products */}
|
||||
{related.length > 0 && (
|
||||
<div className="space-y-8 border-t border-white/5 pt-16">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<p className="text-cyan-glow uppercase tracking-[0.3em] text-xs font-semibold opacity-70 mb-2">
|
||||
More {product.category.name}
|
||||
</p>
|
||||
<h2 className="text-3xl font-bold">Related Products</h2>
|
||||
</div>
|
||||
<Link
|
||||
to={`/category/${product.category.id}`}
|
||||
className="text-cyan-glow text-sm font-bold uppercase tracking-widest hover:underline"
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 md:gap-8">
|
||||
{related.map((p) => (
|
||||
<ProductCard key={p.id} product={p} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
|
||||
{/* Mobile Sticky Add to Cart Bar */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 p-4 bg-night-950/95 backdrop-blur-xl border-t border-white/10 z-40 animate-fade-in flex flex-col gap-3 shadow-[0_-10px_30px_rgba(0,0,0,0.5)]">
|
||||
|
||||
{/* Mobile Variant Selector */}
|
||||
{product.variants && product.variants.length > 1 && (
|
||||
<div className="flex gap-2 overflow-x-auto pb-1" style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}>
|
||||
{product.variants.map((v, i) => (
|
||||
<button
|
||||
key={`mobile-v-${v.id}`}
|
||||
onClick={() => setSelectedVariant(i)}
|
||||
className={`flex-shrink-0 px-4 py-2 rounded-lg text-[10px] font-bold uppercase tracking-widest transition-all border whitespace-nowrap ${
|
||||
selectedVariant === i
|
||||
? 'bg-cyan-glow/10 border-cyan-glow text-cyan-glow'
|
||||
: 'bg-white/5 border-white/10 text-slate-400'
|
||||
}`}
|
||||
>
|
||||
{v.size}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[10px] uppercase tracking-widest text-slate-400">
|
||||
{product.variants && product.variants.length > 1 ? currentVariant?.size : 'Total Price'}
|
||||
</span>
|
||||
<span className="text-xl font-bold text-cyan-glow">
|
||||
{formatPrice(currentVariant?.price)}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleAddToCart}
|
||||
className="flex-1 py-3.5 bg-cyan-glow text-night-900 font-bold rounded-lg tracking-widest uppercase hover:brightness-110 shadow-[0_0_15px_rgba(102,252,241,0.3)] transition-all flex justify-center items-center gap-2"
|
||||
>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useSearchParams, Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import ProductCard from '../components/ProductCard'
|
||||
import { API_BASE, type Category, type Product } from '../types'
|
||||
|
||||
export default function SearchPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const query = searchParams.get('q') ?? ''
|
||||
|
||||
const [input, setInput] = useState(query)
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [products, setProducts] = useState<Product[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [searched, setSearched] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`).then(r => r.json()).then(setCategories).catch(console.error)
|
||||
}, [])
|
||||
|
||||
const doSearch = useCallback(async (q: string) => {
|
||||
if (!q.trim()) { setProducts([]); setSearched(false); return }
|
||||
setLoading(true)
|
||||
setSearched(true)
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/products?search=${encodeURIComponent(q)}`)
|
||||
const paginated = await res.json()
|
||||
setProducts(paginated.data || [])
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Search when URL query changes
|
||||
useEffect(() => {
|
||||
setInput(query)
|
||||
doSearch(query)
|
||||
}, [query, doSearch])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setSearchParams(input.trim() ? { q: input.trim() } : {})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
{/* Search Header */}
|
||||
<div className="pt-32 pb-16 px-6 bg-night-800/20 border-b border-white/5">
|
||||
<div className="max-w-3xl mx-auto space-y-6 text-center">
|
||||
<p className="text-cyan-glow uppercase tracking-[0.4em] text-xs font-semibold opacity-70">
|
||||
Discover
|
||||
</p>
|
||||
<h1 className="text-5xl font-bold">Search Products</h1>
|
||||
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex glass border border-white/10 rounded-2xl overflow-hidden focus-within:border-cyan-glow/40 transition-colors">
|
||||
<svg className="w-5 h-5 text-slate-400 m-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="Search by scent name, category..."
|
||||
className="bg-transparent border-none outline-none text-base p-4 pl-0 w-full text-white placeholder-slate-500"
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-white text-night-900 px-8 font-bold text-xs uppercase tracking-widest hover:bg-cyan-glow transition-colors shrink-0"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Quick category links */}
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{categories.map((cat) => (
|
||||
<Link
|
||||
key={cat.id}
|
||||
to={`/search?q=${encodeURIComponent(cat.name)}`}
|
||||
className="glass px-4 py-1.5 rounded-full text-xs font-medium hover:border-cyan-glow/30 hover:text-cyan-glow transition-all duration-300"
|
||||
>
|
||||
{cat.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="max-w-7xl mx-auto px-6 py-16 space-y-8">
|
||||
{!searched && !query && (
|
||||
<div className="text-center py-20 space-y-3 text-slate-500">
|
||||
<p className="text-5xl">🔍</p>
|
||||
<p className="text-lg text-slate-400">Type something to discover our collection</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{searched && (
|
||||
<p className="text-slate-400 text-sm">
|
||||
{loading ? 'Searching...' : `${products.length} result${products.length !== 1 ? 's' : ''} for `}
|
||||
{!loading && <span className="text-white font-semibold">"{query}"</span>}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="rounded-2xl bg-white/5 animate-pulse h-96" />
|
||||
))}
|
||||
</div>
|
||||
) : searched && products.length === 0 ? (
|
||||
<div className="text-center py-20 space-y-4">
|
||||
<p className="text-5xl">🌿</p>
|
||||
<p className="text-xl text-slate-400">No results found</p>
|
||||
<p className="text-slate-500 text-sm">Try a different keyword or browse our categories</p>
|
||||
<Link to="/shop" className="inline-block mt-4 px-8 py-3 bg-white/10 hover:bg-white hover:text-night-900 text-white font-bold rounded-lg uppercase tracking-widest text-xs transition-all duration-300">
|
||||
Browse All Products
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{products.map((p) => <ProductCard key={p.id} product={p} />)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useNavigate, Link } from 'react-router-dom'
|
||||
import Navbar from '../components/Navbar'
|
||||
import Footer from '../components/Footer'
|
||||
import { API_BASE, type Category } from '../types'
|
||||
import { Search, MapPin, Package, Loader2, ChevronRight } from 'lucide-react'
|
||||
|
||||
export default function TrackOrderPage() {
|
||||
const navigate = useNavigate()
|
||||
const [categories, setCategories] = useState<Category[]>([])
|
||||
const [localOrders, setLocalOrders] = useState<number[]>([])
|
||||
|
||||
const [orderId, setOrderId] = useState('')
|
||||
const [phone, setPhone] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/categories`)
|
||||
.then(res => res.json())
|
||||
.then(setCategories)
|
||||
.catch(console.error)
|
||||
|
||||
// Load local orders
|
||||
const storedStr = localStorage.getItem('irelax_local_orders')
|
||||
if (storedStr) {
|
||||
setLocalOrders(JSON.parse(storedStr))
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleTrackForm = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE}/orders/track`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
orderId: parseInt(orderId.replace(/[^0-9]/g, ''), 10) || 0,
|
||||
phone
|
||||
})
|
||||
})
|
||||
const data = await res.json()
|
||||
|
||||
if (res.ok && data.success) {
|
||||
// Automatically save to local history so they don't have to type it again
|
||||
if (!localOrders.includes(data.orderId)) {
|
||||
const newOrders = [data.orderId, ...localOrders]
|
||||
setLocalOrders(newOrders)
|
||||
localStorage.setItem('irelax_local_orders', JSON.stringify(newOrders))
|
||||
}
|
||||
navigate(`/order/${data.orderId}`)
|
||||
} else {
|
||||
setError(data.error || 'Failed to authenticate order details.')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
setError('An error occurred. Please check your internet connection.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar categories={categories} />
|
||||
|
||||
<main className="flex-1 max-w-4xl w-full mx-auto px-6 flex flex-col justify-center pt-32 pb-24 space-y-12 animate-fade-in">
|
||||
|
||||
{/* Header */}
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-16 h-16 bg-cyan-glow/10 rounded-full flex items-center justify-center mx-auto border border-cyan-glow/20">
|
||||
<MapPin className="w-8 h-8 text-cyan-glow" />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold tracking-tight">Track Your Status</h1>
|
||||
<p className="text-slate-400">Enter your order details below to check shipping status or upload payment slips.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-12">
|
||||
|
||||
{/* Tracking Form */}
|
||||
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6 self-start">
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-white/10 pb-4 flex items-center gap-2 text-cyan-glow">
|
||||
<Search className="w-5 h-5" /> Manual Search
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleTrackForm} className="space-y-5">
|
||||
{error && (
|
||||
<div className="p-4 bg-rose-500/10 border border-rose-500/20 text-rose-400 rounded-lg text-sm text-center">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Order Number (ID)</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={orderId}
|
||||
onChange={e => setOrderId(e.target.value)}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3.5 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all placeholder:text-slate-600"
|
||||
placeholder="e.g. 1024"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs uppercase font-bold tracking-widest text-slate-500">Phone Number</label>
|
||||
<input
|
||||
type="tel"
|
||||
required
|
||||
value={phone}
|
||||
onChange={e => setPhone(e.target.value)}
|
||||
className="w-full bg-night-950 border border-white/10 rounded-lg p-3.5 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all placeholder:text-slate-600"
|
||||
placeholder="The phone number used during checkout"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_20px_rgba(102,252,241,0.3)] transition-all duration-500 disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Find Order'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Local Order History */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-bold uppercase tracking-widest border-b border-white/10 pb-4 flex items-center gap-2 text-white">
|
||||
<Package className="w-5 h-5 text-slate-400" /> Recent Device Orders
|
||||
</h2>
|
||||
|
||||
{localOrders.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white/5 rounded-2xl border border-dashed border-white/10 space-y-3">
|
||||
<p className="text-slate-500 text-sm">No recent orders found on this device.</p>
|
||||
<p className="text-[10px] uppercase text-slate-600 tracking-widest">Use manual search if you ordered elsewhere</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{localOrders.map((id) => (
|
||||
<Link
|
||||
key={id}
|
||||
to={`/order/${id}`}
|
||||
className="flex justify-between items-center p-5 bg-white/5 rounded-xl border border-white/5 hover:bg-white/10 hover:border-cyan-glow/50 transition-all group"
|
||||
>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 uppercase tracking-widest mb-1">Order</p>
|
||||
<p className="text-xl font-bold font-mono text-cyan-glow">#{id.toString().padStart(4, '0')}</p>
|
||||
</div>
|
||||
<div className="w-10 h-10 rounded-full bg-night-950 flex items-center justify-center group-hover:bg-cyan-glow transition-colors">
|
||||
<ChevronRight className="w-5 h-5 text-slate-500 group-hover:text-night-900" />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<Footer categories={categories} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { create } from "zustand";
|
||||
import { persist } from "zustand/middleware";
|
||||
|
||||
export interface Customer {
|
||||
id: number;
|
||||
lineUserId: string;
|
||||
displayName: string | null;
|
||||
pictureUrl: string | null;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
customer: Customer | null;
|
||||
isLiffInitialized: boolean;
|
||||
setAuth: (token: string, customer: Customer) => void;
|
||||
setLiffInitialized: (status: boolean) => void;
|
||||
logout: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
token: null,
|
||||
customer: null,
|
||||
isLiffInitialized: false,
|
||||
setAuth: (token, customer) => set({ token, customer }),
|
||||
setLiffInitialized: (isLiffInitialized) => set({ isLiffInitialized }),
|
||||
logout: () => set({ token: null, customer: null }),
|
||||
}),
|
||||
{
|
||||
name: "irelax-auth-storage",
|
||||
partialize: (state) => ({ token: state.token, customer: state.customer }), // don't persist isLiffInitialized
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
export * from '@homaroma/types'
|
||||
Reference in new issue
Block a user