Rename ssr to website, website to spa, add spa nginx config

This commit is contained in:
Your Name committed 2026-06-03 09:38:15 +07:00
1 parent fc6bf0218e
commit 8514342c2f
103 files changed
+791 -758

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
VITE_LIFF_ID="2009944751-5IYZT5kH"
VITE_API_URL="https://api.homaroma.shop/api"
+26
View File
@@ -0,0 +1,26 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
!.env.production
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
File renamed without changes.
File renamed without changes.
+41
View File
@@ -0,0 +1,41 @@
{
"name": "homaroma-spa",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@homaroma/types": "workspace:*",
"@line/liff": "^2.28.0",
"i18next": "^26.0.4",
"lucide-react": "^1.17.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+214
View File
@@ -0,0 +1,214 @@
import React from 'react'
import { X, ShoppingBag, Plus, Minus, Trash2, Tag, ArrowRight } from 'lucide-react'
import { Link } from 'react-router-dom'
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()
const { isCartOpen, toggleCart, items, updateQuantity, removeFromCart, getCartTotal } = useCartStore()
if (!isCartOpen) return null
return (
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm animate-fade-in"
onClick={() => toggleCart(false)}
/>
{/* Drawer */}
<div className="relative w-full max-w-md h-full bg-night-900 shadow-2xl flex flex-col border-l border-white/10 animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-night-950/50">
<h2 className="text-xl font-bold flex items-center gap-2">
<ShoppingBag className="w-5 h-5 text-cyan-glow" />
{t('cart.title')}
<span className="text-sm font-normal text-slate-400 bg-white/5 px-2 py-0.5 rounded-full ml-2">
{t('cart.items', { count: items.length })}
</span>
</h2>
<button
onClick={() => toggleCart(false)}
className="p-2 hover:bg-white/5 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400 hover:text-white" />
</button>
</div>
{/* Cart Items */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{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" />
<p>{t('cart.empty')}</p>
<button
onClick={() => toggleCart(false)}
className="text-cyan-glow hover:underline tracking-widest uppercase text-xs font-bold"
>
{t('cart.continueShopping')}
</button>
</div>
) : (
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' }}>
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="w-full h-full object-cover opacity-80"
/>
</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>
{item.variantSize && (
<p className="text-xs text-slate-500">{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>
{/* 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>
<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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
))
)}
</div>
{/* Footer */}
{items.length > 0 && (
<CartDrawerFooter subtotal={getCartTotal()} onClose={() => toggleCart(false)} t={t} />
)}
</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>
)
}
@@ -1,6 +1,5 @@
"use client";
import Link from 'next/link'
import type { Category } from '@homaroma/types'
import { Link } from 'react-router-dom'
import type { Category } from '../types'
import { useTranslation } from 'react-i18next'
interface FooterProps {
@@ -31,12 +30,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 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>
<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>
</div>
</div>
@@ -44,7 +43,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} href={`/category/${c.id}`} className="hover:text-white transition-colors">
<Link key={c.id} to={`/category/${c.id}`} className="hover:text-white transition-colors">
{c.name}
</Link>
))}
@@ -1,10 +1,10 @@
"use client";
import Link from 'next/link'
import { useRouter, usePathname } from 'next/navigation'
import { Link, NavLink, useNavigate } from 'react-router-dom'
import { useState, useEffect } from 'react'
import type { Category } from '@homaroma/types'
import type { Category } from '../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 router = useRouter()
const pathname = usePathname()
const navigate = useNavigate()
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) }, [pathname])
useEffect(() => { setMobileOpen(false) }, [navigate])
// 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()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchOpen(false)
setMobileOpen(false)
setSearchQuery('')
}
}
const navLinkClass = (path: string) =>
pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
const navLinkClass = ({ isActive }: { isActive: boolean }) =>
isActive ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
return (
<>
@@ -74,15 +74,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}`}
>
{/* Logo */}
<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 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>
{/* ── DESKTOP NAV ── */}
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
<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>
<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>
{/* Collections dropdown */}
{categories.length > 0 && (
@@ -98,7 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map((cat) => (
<Link
key={cat.id}
href={`/category/${cat.id}`}
to={`/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>
)}
<Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link>
<Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link>
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
</div>
{/* ── DESKTOP RIGHT ACTIONS ── */}
<div className="hidden md:flex items-center gap-3">
<Link
href="/track"
to="/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
href="/shop"
to="/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,39 +165,91 @@ 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-2">
<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>
)}
<Link
href="/track"
to="/track"
onClick={() => setMobileOpen(false)}
className="relative p-2 rounded-full glass hover:bg-white/10 transition-all"
className="relative p-1.5 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-1 right-1 w-2 h-2 bg-rose-500 rounded-full" />
<span className="absolute top-0 right-0 w-2 h-2 bg-rose-500 rounded-full" />
)}
</Link>
<button
onClick={() => { setSearchOpen(true); setMobileOpen(false) }}
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
aria-label="Search"
>
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<svg className="w-4 h-4" 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-2 rounded-full glass hover:bg-white/10 transition-all"
className="relative p-1.5 rounded-full glass hover:bg-white/10 transition-all"
aria-label="Cart"
>
<ShoppingBag className="w-5 h-5" />
<ShoppingBag className="w-4 h-4" />
{cartCount > 0 && (
<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">
<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">
{cartCount}
</span>
)}
@@ -205,13 +257,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Hamburger */}
<button
onClick={() => setMobileOpen(prev => !prev)}
className="p-2 rounded-full glass hover:bg-white/10 transition-all"
className="p-1.5 rounded-full glass hover:bg-white/10 transition-all"
aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
>
<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 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>
</button>
</div>
@@ -234,6 +286,9 @@ 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>
@@ -248,7 +303,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') },
{ to: '/', label: t('nav.home'), end: true },
{ to: '/shop', label: t('nav.shop') },
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
@@ -256,18 +311,19 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{ to: '/faq', label: t('nav.faq') },
{ to: '/contact', label: t('nav.contact') },
].map(item => (
<Link
<NavLink
key={item.to}
href={item.to}
to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)}
className={
className={({ isActive }) =>
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${
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'
isActive ? 'bg-cyan-glow/10 text-cyan-glow border border-cyan-glow/20' : 'hover:bg-white/5 text-slate-300 hover:text-white'
}`
}
>
{item.label}
</Link>
</NavLink>
))}
{/* Collections sub-section */}
@@ -277,7 +333,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map(cat => (
<Link
key={cat.id}
href={`/category/${cat.id}`}
to={`/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"
>
@@ -294,19 +350,35 @@ 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
href="/shop"
to="/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
href="/scent-guide"
to="/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>
@@ -356,7 +428,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
].map(item => (
<Link
key={item.to}
href={item.to}
to={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 'next/link'
import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '@homaroma/types'
import { Link } from 'react-router-dom'
import type { Product } from '../types'
import { getCategoryImage, formatPrice } from '../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 || product.category.imageUrl || getCategoryImage(product.category.name)}
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}
/>
@@ -52,7 +52,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="flex-1" />
<Link
href={`/products/${product.id}`}
to={`/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
+16
View File
@@ -0,0 +1,16 @@
import { useEffect } from 'react'
import { useLocation } from 'react-router-dom'
/**
* Scrolls window to top on every route change.
* Place this inside <BrowserRouter> so it has access to location context.
*/
export default function ScrollToTop() {
const { pathname } = useLocation()
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' })
}, [pathname])
return null
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
File renamed without changes.
File renamed without changes.
-2
View File
@@ -1,2 +0,0 @@
# Production - ชี้ไป Backend จริงบน server
NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
-44
View File
@@ -1,44 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env
.env.local # secret ไว้บน dev เครื่องตัวเอง
.env*.local
# .env.production ← อนุญาตให้ commit (ไม่มี secret ใดๆ)
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
-36
View File
@@ -1,36 +0,0 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
-40
View File
@@ -1,40 +0,0 @@
{
"name": "ssr",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"@homaroma/types": "workspace:*",
"lucide-react": "^0.475.0",
"zustand": "^5.0.0",
"i18next": "^24.0.0",
"react-i18next": "^15.0.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}
-120
View File
@@ -1,120 +0,0 @@
"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'
export default function CartDrawer() {
const { t } = useTranslation()
const { isCartOpen, toggleCart, items, updateQuantity, removeFromCart, getCartTotal } = useCartStore()
if (!isCartOpen) return null
return (
<div className="fixed inset-0 z-[100] flex justify-end">
{/* Backdrop */}
<div
className="absolute inset-0 bg-night-950/80 backdrop-blur-sm animate-fade-in"
onClick={() => toggleCart(false)}
/>
{/* Drawer */}
<div className="relative w-full max-w-md h-full bg-night-900 shadow-2xl flex flex-col border-l border-white/10 animate-slide-left">
{/* Header */}
<div className="p-6 border-b border-white/10 flex items-center justify-between bg-night-950/50">
<h2 className="text-xl font-bold flex items-center gap-2">
<ShoppingBag className="w-5 h-5 text-cyan-glow" />
{t('cart.title')}
<span className="text-sm font-normal text-slate-400 bg-white/5 px-2 py-0.5 rounded-full ml-2">
{t('cart.items', { count: items.length })}
</span>
</h2>
<button
onClick={() => toggleCart(false)}
className="p-2 hover:bg-white/5 rounded-full transition-colors"
>
<X className="w-5 h-5 text-slate-400 hover:text-white" />
</button>
</div>
{/* Cart Items */}
<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" />
<p>{t('cart.empty')}</p>
<button
onClick={() => toggleCart(false)}
className="text-cyan-glow hover:underline tracking-widest uppercase text-xs font-bold"
>
{t('cart.continueShopping')}
</button>
</div>
) : (
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-20 h-24 rounded-lg bg-night-950 overflow-hidden flex-shrink-0">
<img
src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName}
className="w-full h-full object-cover opacity-80"
/>
</div>
<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-400">{item.variantSize}</p>
)}
<p className="text-cyan-glow text-sm font-bold mt-auto">{formatPrice(item.price)}</p>
<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-1 right-0 p-1 text-slate-500 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* Footer */}
{items.length > 0 && (
<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>
)
}
-16
View File
@@ -1,16 +0,0 @@
"use client";
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
/**
* Scrolls window to top on every route change (Next.js version).
*/
export default function ScrollToTop() {
const pathname = usePathname()
useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' })
}, [pathname])
return null
}
-34
View File
@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_LIFF_ID="2009944751-5IYZT5kH"
VITE_API_URL="https://api.homaroma.shop/api"
# Production - ชี้ไป Backend จริงบน server
NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
+38 -20
View File
@@ -1,26 +1,44 @@
# Logs
logs
*.log
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
.pnpm-debug.log*
node_modules
dist
dist-ssr
*.local
# env files
.env
.env.local # secret ไว้บน dev เครื่องตัวเอง
.env*.local
# .env.production ← อนุญาตให้ commit (ไม่มี secret ใดๆ)
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# vercel
.vercel
!.env.production
# typescript
*.tsbuildinfo
next-env.d.ts
File renamed without changes.
File renamed without changes.
+29 -66
View File
@@ -1,73 +1,36 @@
# React + TypeScript + Vite
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
## Getting Started
Currently, two official plugins are available:
First, run the development server:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
File renamed without changes.
File renamed without changes.
+31 -32
View File
@@ -1,41 +1,40 @@
{
"name": "website",
"name": "homaroma-website",
"version": "0.1.0",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"@homaroma/types": "workspace:*",
"@line/liff": "^2.28.0",
"i18next": "^26.0.4",
"lucide-react": "^1.17.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
"lucide-react": "^0.475.0",
"zustand": "^5.0.0",
"i18next": "^24.0.0",
"react-i18next": "^15.0.0",
"axios": "^1.7.0"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/postcss": "^4.2.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.27",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.2.4",
"tailwindcss": "^4",
"typescript": "^5"
},
"ignoreScripts": [
"sharp",
"unrs-resolver"
],
"trustedDependencies": [
"sharp",
"unrs-resolver"
]
}
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+29 -123
View File
@@ -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>
)}
<p className="text-cyan-glow text-sm font-bold mt-auto">{formatPrice(item.price)}</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>
{/* 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">
<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.5 hover:text-cyan-glow transition-colors"
className="p-1 hover:text-cyan-glow transition-colors"
>
<Minus className="w-3 h-3" />
<Minus className="w-4 h-4" />
</button>
<span className="w-5 text-center text-xs font-bold">{item.quantity}</span>
<span className="w-4 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"
className="p-1 hover:text-cyan-glow transition-colors"
>
<Plus className="w-3 h-3" />
<Plus className="w-4 h-4" />
</button>
</div>
</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>
</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 */}
<p className="text-xs text-slate-500 text-center">{t('cart.shippingNote')}</p>
<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"
href="/checkout"
onClick={() => toggleCart(false)}
className="btn-primary w-full py-4 text-center block uppercase tracking-widest text-sm"
>
<ShoppingBag className="w-4 h-4" />
ดำเนินการชำระเงิน
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
{t('cart.proceed')}
</Link>
</div>
)}
</div>
</div>
)
}
+10 -9
View File
@@ -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>
))}
+44 -116
View File
@@ -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"
>
+5 -5
View File
@@ -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
+4 -4
View File
@@ -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' })
File renamed without changes.
File renamed without changes.
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff. Show more