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 'react-router-dom'
import Link from 'next/link' import type { Category } from '../types'
import type { Category } from '@homaroma/types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface FooterProps { interface FooterProps {
@@ -31,12 +30,12 @@ export default function Footer({ categories = [] }: FooterProps) {
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4> <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"> <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 to="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
<Link href="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link> <Link to="/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 to="/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 to="/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 to="/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="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
</div> </div>
</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> <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"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
{categories.map((c) => ( {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} {c.name}
</Link> </Link>
))} ))}
@@ -1,10 +1,10 @@
"use client"; import { Link, NavLink, useNavigate } from 'react-router-dom'
import Link from 'next/link'
import { useRouter, usePathname } from 'next/navigation'
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import type { Category } from '@homaroma/types' import type { Category } from '../types'
import { ShoppingBag, MapPin } from 'lucide-react' import { ShoppingBag, MapPin } from 'lucide-react'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { logoutLiff, loginLiff } from '../lib/liff'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface NavbarProps { interface NavbarProps {
@@ -17,11 +17,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
const [localOrdersCount, setLocalOrdersCount] = useState(0) const [localOrdersCount, setLocalOrdersCount] = useState(0)
const router = useRouter() const navigate = useNavigate()
const pathname = usePathname()
const { toggleCart, getCartCount } = useCartStore() const { toggleCart, getCartCount } = useCartStore()
const cartCount = getCartCount() const cartCount = getCartCount()
const { customer } = useAuthStore()
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const toggleLanguage = () => { const toggleLanguage = () => {
@@ -45,7 +45,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}, []) }, [])
// Close mobile menu on route change // Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [pathname]) useEffect(() => { setMobileOpen(false) }, [navigate])
// Prevent body scroll when overlays open // Prevent body scroll when overlays open
useEffect(() => { useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : '' document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
@@ -55,15 +55,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (searchQuery.trim()) { if (searchQuery.trim()) {
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`) navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchOpen(false) setSearchOpen(false)
setMobileOpen(false) setMobileOpen(false)
setSearchQuery('') setSearchQuery('')
} }
} }
const navLinkClass = (path: string) => const navLinkClass = ({ isActive }: { isActive: boolean }) =>
pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors' isActive ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
return ( return (
<> <>
@@ -74,15 +74,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}`} }`}
> >
{/* Logo */} {/* Logo */}
<Link href="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0"> <Link to="/" 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> 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> </Link>
{/* ── DESKTOP NAV ── */} {/* ── DESKTOP NAV ── */}
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase"> <div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
<Link href="/" className={navLinkClass('/')}>{t('nav.home')}</Link> <NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink>
<Link href="/shop" className={navLinkClass('/shop')}>{t('nav.shop')}</Link> <NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink>
<Link href="/coupons" className={navLinkClass('/coupons')}>🔥 {t('nav.promotions') || 'Promotions'}</Link> <NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink>
{/* Collections dropdown */} {/* Collections dropdown */}
{categories.length > 0 && ( {categories.length > 0 && (
@@ -98,7 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map((cat) => ( {categories.map((cat) => (
<Link <Link
key={cat.id} 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" 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} {cat.name}
@@ -112,14 +112,14 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</div> </div>
)} )}
<Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link> <NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink>
<Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link> <NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink>
</div> </div>
{/* ── DESKTOP RIGHT ACTIONS ── */} {/* ── DESKTOP RIGHT ACTIONS ── */}
<div className="hidden md:flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
<Link <Link
href="/track" to="/track"
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group" className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
aria-label="Track Orders" aria-label="Track Orders"
title="Track Orders" title="Track Orders"
@@ -153,7 +153,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
)} )}
</button> </button>
<Link <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" 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')} {t('nav.shopNow')}
@@ -165,39 +165,91 @@ export default function Navbar({ categories = [] }: NavbarProps) {
> >
{i18n.language.startsWith('en') ? 'EN' : 'TH'} {i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button> </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> </div>
{/* ── MOBILE RIGHT ACTIONS ── */} {/* ── 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 <Link
href="/track" to="/track"
onClick={() => setMobileOpen(false)} 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" aria-label="Track Orders"
> >
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
{localOrdersCount > 0 && ( {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> </Link>
<button <button
onClick={() => { setSearchOpen(true); setMobileOpen(false) }} 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" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</button> </button>
<button <button
onClick={() => { toggleCart(true); setMobileOpen(false) }} 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" aria-label="Cart"
> >
<ShoppingBag className="w-5 h-5" /> <ShoppingBag className="w-4 h-4" />
{cartCount > 0 && ( {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} {cartCount}
</span> </span>
)} )}
@@ -205,13 +257,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Hamburger */} {/* Hamburger */}
<button <button
onClick={() => setMobileOpen(prev => !prev)} 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'} aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
> >
<div className="w-5 h-5 flex flex-col justify-center gap-[5px] relative"> <div className="w-4 h-4 flex flex-col justify-center gap-[4px] relative">
<span className={`block w-5 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} /> <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-4'}`} /> <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-5' : 'w-5'}`} /> <span className={`block h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute -rotate-45 w-4' : 'w-4'}`} />
</div> </div>
</button> </button>
</div> </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"> <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> <span className="text-sm font-bold uppercase tracking-widest text-slate-300">{t('nav.menu')}</span>
<div className="flex items-center gap-3"> <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"> <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'} {i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button> </button>
@@ -248,7 +303,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Drawer Nav Links */} {/* Drawer Nav Links */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1"> <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: '/shop', label: t('nav.shop') },
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` }, { to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` }, { to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
@@ -256,18 +311,19 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{ to: '/faq', label: t('nav.faq') }, { to: '/faq', label: t('nav.faq') },
{ to: '/contact', label: t('nav.contact') }, { to: '/contact', label: t('nav.contact') },
].map(item => ( ].map(item => (
<Link <NavLink
key={item.to} key={item.to}
href={item.to} to={item.to}
end={item.end}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={ className={({ isActive }) =>
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${ `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} {item.label}
</Link> </NavLink>
))} ))}
{/* Collections sub-section */} {/* Collections sub-section */}
@@ -277,7 +333,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map(cat => ( {categories.map(cat => (
<Link <Link
key={cat.id} key={cat.id}
href={`/category/${cat.id}`} to={`/category/${cat.id}`}
onClick={() => setMobileOpen(false)} 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" 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 */} {/* Drawer Footer CTA */}
<div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3"> <div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3">
<Link <Link
href="/shop" to="/shop"
onClick={() => setMobileOpen(false)} 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" 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')} {t('nav.shopNow')}
</Link> </Link>
<Link <Link
href="/scent-guide" to="/scent-guide"
onClick={() => setMobileOpen(false)} 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" 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')} {t('nav.findMyScent')}
</Link> </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> </div>
</div> </div>
@@ -356,7 +428,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
].map(item => ( ].map(item => (
<Link <Link
key={item.to} key={item.to}
href={item.to} to={item.to}
onClick={() => setSearchOpen(false)} 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" 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 { Link } from 'react-router-dom'
import type { Product } from '@homaroma/types' import type { Product } from '../types'
import { getCategoryImage, formatPrice } from '@homaroma/types' import { getCategoryImage, formatPrice } from '../types'
interface ProductCardProps { interface ProductCardProps {
product: Product 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="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"> <div className="h-40 md:h-64 overflow-hidden rounded-xl relative">
<img <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" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name} alt={product.scent.name}
/> />
@@ -52,7 +52,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="flex-1" /> <div className="flex-1" />
<Link <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" 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 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" # Production - ชี้ไป Backend จริงบน server
VITE_API_URL="https://api.homaroma.shop/api" NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
+38 -20
View File
@@ -1,26 +1,44 @@
# Logs # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
logs
*.log # 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* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
pnpm-debug.log* .pnpm-debug.log*
lerna-debug.log*
node_modules # env files
dist .env
dist-ssr .env.local # secret ไว้บน dev เครื่องตัวเอง
*.local .env*.local
# .env.production ← อนุญาตให้ commit (ไม่มี secret ใดๆ)
# Editor directories and files # vercel
.vscode/* .vercel
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
!.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) ```bash
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) npm run dev
# or
## React Compiler yarn dev
# or
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). pnpm dev
# or
## Expanding the ESLint configuration bun dev
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: Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
```js You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([ 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.
globalIgnores(['dist']),
{ ## Learn More
files: ['**/*.{ts,tsx}'],
extends: [ To learn more about Next.js, take a look at the following resources:
// Other configs...
// Enable lint rules for React - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
reactX.configs['recommended-typescript'], - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
// Enable lint rules for React DOM
reactDom.configs.recommended, You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
],
languageOptions: { ## Deploy on Vercel
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'], 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.
tsconfigRootDir: import.meta.dirname,
}, Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
// other options...
},
},
])
```
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, "private": true,
"version": "0.0.0",
"type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "next dev -p 3001",
"build": "tsc -b && vite build", "build": "next build",
"lint": "eslint .", "start": "next start",
"preview": "vite preview" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"next": "16.2.4",
"react": "19.2.4",
"react-dom": "19.2.4",
"@homaroma/types": "workspace:*", "@homaroma/types": "workspace:*",
"@line/liff": "^2.28.0", "lucide-react": "^0.475.0",
"i18next": "^26.0.4", "zustand": "^5.0.0",
"lucide-react": "^1.17.0", "i18next": "^24.0.0",
"react": "^19.2.4", "react-i18next": "^15.0.0",
"react-dom": "^19.2.4", "axios": "^1.7.0"
"react-i18next": "^17.0.2",
"react-router-dom": "^7.14.0",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@tailwindcss/postcss": "^4",
"@tailwindcss/postcss": "^4.2.2", "@types/node": "^20",
"@types/node": "^24.12.0", "@types/react": "^19",
"@types/react": "^19.2.14", "@types/react-dom": "^19",
"@types/react-dom": "^19.2.3", "eslint": "^9",
"@vitejs/plugin-react": "^6.0.1", "eslint-config-next": "16.2.4",
"autoprefixer": "^10.4.27", "tailwindcss": "^4",
"eslint": "^9.39.4", "typescript": "^5"
"eslint-plugin-react-hooks": "^7.0.1", },
"eslint-plugin-react-refresh": "^0.5.2", "ignoreScripts": [
"globals": "^17.4.0", "sharp",
"postcss": "^8.5.8", "unrs-resolver"
"tailwindcss": "^4.2.2", ],
"typescript": "~5.9.3", "trustedDependencies": [
"typescript-eslint": "^8.57.0", "sharp",
"vite": "^8.0.1" "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' "use client";
import { X, ShoppingBag, Plus, Minus, Trash2, Tag, ArrowRight } from 'lucide-react' import { X, ShoppingBag, Plus, Minus, Trash2 } from 'lucide-react'
import { Link } from 'react-router-dom' import Link from 'next/link'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { formatPrice } from '@homaroma/types' import { formatPrice } from '@homaroma/types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { API_BASE } from '../types'
export default function CartDrawer() { export default function CartDrawer() {
const { t } = useTranslation() const { t } = useTranslation()
@@ -40,7 +39,7 @@ export default function CartDrawer() {
</div> </div>
{/* Cart Items */} {/* 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 ? ( {items.length === 0 ? (
<div className="h-full flex flex-col items-center justify-center text-slate-500 space-y-4"> <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" /> <ShoppingBag className="w-16 h-16 opacity-20" />
@@ -55,7 +54,7 @@ export default function CartDrawer() {
) : ( ) : (
items.map((item) => ( 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 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 <img
src={item.productImage || '/images/oil_ozone.png'} src={item.productImage || '/images/oil_ozone.png'}
alt={item.productName} alt={item.productName}
@@ -63,46 +62,34 @@ export default function CartDrawer() {
/> />
</div> </div>
<div className="flex-1 flex flex-col pt-1 space-y-1 relative min-w-0"> <div className="flex-1 flex flex-col pt-1 space-y-1 relative">
<h3 className="font-bold text-sm leading-tight pr-6 truncate">{item.productName}</h3> <h3 className="font-bold text-sm leading-tight pr-6">{item.productName}</h3>
{item.variantSize && ( {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-center gap-3 mt-2 bg-night-950/50 rounded-lg w-max border border-white/5">
<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 <button
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)} 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> </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 <button
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)} 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> </button>
</div> </div>
</div>
<button <button
onClick={() => removeFromCart(item.cartItemId)} onClick={() => removeFromCart(item.cartItemId)}
className="absolute top-0 right-0 p-1.5 text-slate-600 hover:text-rose-500 transition-colors" className="absolute top-1 right-0 p-1 text-slate-500 hover:text-rose-500 transition-colors opacity-0 group-hover:opacity-100"
title="Remove item"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
</div> </div>
@@ -112,103 +99,22 @@ export default function CartDrawer() {
{/* Footer */} {/* Footer */}
{items.length > 0 && ( {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>
</div> <p className="text-xs text-slate-500 text-center">{t('cart.shippingNote')}</p>
)
}
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 <Link
to="/checkout" href="/checkout"
onClick={onClose} onClick={() => toggleCart(false)}
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" className="btn-primary w-full py-4 text-center block uppercase tracking-widest text-sm"
> >
<ShoppingBag className="w-4 h-4" /> {t('cart.proceed')}
ดำเนินการชำระเงิน
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
</Link> </Link>
</div> </div>
)}
</div>
</div>
) )
} }
+10 -9
View File
@@ -1,5 +1,6 @@
import { Link } from 'react-router-dom' "use client";
import type { Category } from '../types' import Link from 'next/link'
import type { Category } from '@homaroma/types'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface FooterProps { interface FooterProps {
@@ -30,12 +31,12 @@ export default function Footer({ categories = [] }: FooterProps) {
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-bold uppercase tracking-widest text-xs text-white">{t('footer.explore')}</h4> <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"> <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 href="/" className="hover:text-white transition-colors">{t('nav.home')}</Link>
<Link to="/shop" className="hover:text-white transition-colors">{t('nav.shop')}</Link> <Link href="/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 href="/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 href="/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 href="/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="/faq" className="hover:text-white transition-colors">{t('nav.faq')}</Link>
</div> </div>
</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> <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"> <div className="flex flex-col space-y-2 text-sm text-slate-400">
{categories.map((c) => ( {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} {c.name}
</Link> </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 { useState, useEffect } from 'react'
import type { Category } from '../types' import type { Category } from '@homaroma/types'
import { ShoppingBag, MapPin } from 'lucide-react' import { ShoppingBag, MapPin } from 'lucide-react'
import { useCartStore } from '../store/cartStore' import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { logoutLiff, loginLiff } from '../lib/liff'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
interface NavbarProps { interface NavbarProps {
@@ -17,11 +17,11 @@ export default function Navbar({ categories = [] }: NavbarProps) {
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [scrolled, setScrolled] = useState(false) const [scrolled, setScrolled] = useState(false)
const [localOrdersCount, setLocalOrdersCount] = useState(0) const [localOrdersCount, setLocalOrdersCount] = useState(0)
const navigate = useNavigate() const router = useRouter()
const pathname = usePathname()
const { toggleCart, getCartCount } = useCartStore() const { toggleCart, getCartCount } = useCartStore()
const cartCount = getCartCount() const cartCount = getCartCount()
const { customer } = useAuthStore()
const { t, i18n } = useTranslation() const { t, i18n } = useTranslation()
const toggleLanguage = () => { const toggleLanguage = () => {
@@ -45,7 +45,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}, []) }, [])
// Close mobile menu on route change // Close mobile menu on route change
useEffect(() => { setMobileOpen(false) }, [navigate]) useEffect(() => { setMobileOpen(false) }, [pathname])
// Prevent body scroll when overlays open // Prevent body scroll when overlays open
useEffect(() => { useEffect(() => {
document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : '' document.body.style.overflow = (searchOpen || mobileOpen) ? 'hidden' : ''
@@ -55,15 +55,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
const handleSearch = (e: React.FormEvent) => { const handleSearch = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (searchQuery.trim()) { if (searchQuery.trim()) {
navigate(`/search?q=${encodeURIComponent(searchQuery.trim())}`) router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
setSearchOpen(false) setSearchOpen(false)
setMobileOpen(false) setMobileOpen(false)
setSearchQuery('') setSearchQuery('')
} }
} }
const navLinkClass = ({ isActive }: { isActive: boolean }) => const navLinkClass = (path: string) =>
isActive ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors' pathname === path ? 'text-cyan-glow' : 'text-slate-300 hover:text-white transition-colors'
return ( return (
<> <>
@@ -74,15 +74,15 @@ export default function Navbar({ categories = [] }: NavbarProps) {
}`} }`}
> >
{/* Logo */} {/* Logo */}
<Link to="/" onClick={() => setMobileOpen(false)} className="text-xl md:text-2xl font-bold tracking-widest text-white uppercase italic shrink-0"> <Link href="/" 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> iRelax <span className="text-cyan-glow text-xs md:text-sm not-italic font-light opacity-60">Essential</span>
</Link> </Link>
{/* ── DESKTOP NAV ── */} {/* ── DESKTOP NAV ── */}
<div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase"> <div className="hidden md:flex space-x-7 text-sm font-medium tracking-wide uppercase">
<NavLink to="/" end className={navLinkClass}>{t('nav.home')}</NavLink> <Link href="/" className={navLinkClass('/')}>{t('nav.home')}</Link>
<NavLink to="/shop" className={navLinkClass}>{t('nav.shop')}</NavLink> <Link href="/shop" className={navLinkClass('/shop')}>{t('nav.shop')}</Link>
<NavLink to="/coupons" className={navLinkClass}>🔥 {t('nav.promotions') || 'Promotions'}</NavLink> <Link href="/coupons" className={navLinkClass('/coupons')}>🔥 {t('nav.promotions') || 'Promotions'}</Link>
{/* Collections dropdown */} {/* Collections dropdown */}
{categories.length > 0 && ( {categories.length > 0 && (
@@ -98,7 +98,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map((cat) => ( {categories.map((cat) => (
<Link <Link
key={cat.id} 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" 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} {cat.name}
@@ -112,14 +112,14 @@ export default function Navbar({ categories = [] }: NavbarProps) {
</div> </div>
)} )}
<NavLink to="/scent-guide" className={navLinkClass}>{t('nav.scentGuide')}</NavLink> <Link href="/scent-guide" className={navLinkClass('/scent-guide')}>{t('nav.scentGuide')}</Link>
<NavLink to="/about" className={navLinkClass}>{t('nav.about')}</NavLink> <Link href="/about" className={navLinkClass('/about')}>{t('nav.about')}</Link>
</div> </div>
{/* ── DESKTOP RIGHT ACTIONS ── */} {/* ── DESKTOP RIGHT ACTIONS ── */}
<div className="hidden md:flex items-center gap-3"> <div className="hidden md:flex items-center gap-3">
<Link <Link
to="/track" href="/track"
className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group" className="relative glass p-2.5 rounded-full hover:bg-white/10 transition-all group"
aria-label="Track Orders" aria-label="Track Orders"
title="Track Orders" title="Track Orders"
@@ -153,7 +153,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
)} )}
</button> </button>
<Link <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" 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')} {t('nav.shopNow')}
@@ -165,91 +165,39 @@ export default function Navbar({ categories = [] }: NavbarProps) {
> >
{i18n.language.startsWith('en') ? 'EN' : 'TH'} {i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button> </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> </div>
{/* ── MOBILE RIGHT ACTIONS ── */} {/* ── MOBILE RIGHT ACTIONS ── */}
<div className="flex md:hidden items-center gap-1.5"> <div className="flex md:hidden items-center gap-2">
{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 <Link
to="/track" href="/track"
onClick={() => setMobileOpen(false)} 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" aria-label="Track Orders"
> >
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
{localOrdersCount > 0 && ( {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> </Link>
<button <button
onClick={() => { setSearchOpen(true); setMobileOpen(false) }} 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" 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" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg> </svg>
</button> </button>
<button <button
onClick={() => { toggleCart(true); setMobileOpen(false) }} 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" aria-label="Cart"
> >
<ShoppingBag className="w-4 h-4" /> <ShoppingBag className="w-5 h-5" />
{cartCount > 0 && ( {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} {cartCount}
</span> </span>
)} )}
@@ -257,13 +205,13 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Hamburger */} {/* Hamburger */}
<button <button
onClick={() => setMobileOpen(prev => !prev)} 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'} aria-label={mobileOpen ? 'Close menu' : 'Open menu'}
> >
<div className="w-4 h-4 flex flex-col justify-center gap-[4px] relative"> <div className="w-5 h-5 flex flex-col justify-center gap-[5px] relative">
<span className={`block w-4 h-0.5 bg-white rounded-full transition-all duration-300 ${mobileOpen ? 'absolute rotate-45' : ''}`} /> <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-3'}`} /> <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-4' : '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> </div>
</button> </button>
</div> </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"> <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> <span className="text-sm font-bold uppercase tracking-widest text-slate-300">{t('nav.menu')}</span>
<div className="flex items-center gap-3"> <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"> <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'} {i18n.language.startsWith('en') ? 'EN' : 'TH'}
</button> </button>
@@ -303,7 +248,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{/* Drawer Nav Links */} {/* Drawer Nav Links */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-1"> <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: '/shop', label: t('nav.shop') },
{ to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` }, { to: '/coupons', label: `🔥 ${t('nav.promotions') || 'Promotions'}` },
{ to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` }, { to: '/scent-guide', label: `✨ ${t('nav.scentGuide')}` },
@@ -311,19 +256,18 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{ to: '/faq', label: t('nav.faq') }, { to: '/faq', label: t('nav.faq') },
{ to: '/contact', label: t('nav.contact') }, { to: '/contact', label: t('nav.contact') },
].map(item => ( ].map(item => (
<NavLink <Link
key={item.to} key={item.to}
to={item.to} href={item.to}
end={item.end}
onClick={() => setMobileOpen(false)} onClick={() => setMobileOpen(false)}
className={({ isActive }) => className={
`block px-4 py-3 rounded-xl text-sm font-medium tracking-wide transition-all duration-200 ${ `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} {item.label}
</NavLink> </Link>
))} ))}
{/* Collections sub-section */} {/* Collections sub-section */}
@@ -333,7 +277,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
{categories.map(cat => ( {categories.map(cat => (
<Link <Link
key={cat.id} key={cat.id}
to={`/category/${cat.id}`} href={`/category/${cat.id}`}
onClick={() => setMobileOpen(false)} 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" 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 */} {/* Drawer Footer CTA */}
<div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3"> <div className="px-4 pb-8 pt-4 border-t border-white/5 space-y-3">
<Link <Link
to="/shop" href="/shop"
onClick={() => setMobileOpen(false)} 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" 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')} {t('nav.shopNow')}
</Link> </Link>
<Link <Link
to="/scent-guide" href="/scent-guide"
onClick={() => setMobileOpen(false)} 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" 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')} {t('nav.findMyScent')}
</Link> </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> </div>
</div> </div>
@@ -428,7 +356,7 @@ export default function Navbar({ categories = [] }: NavbarProps) {
].map(item => ( ].map(item => (
<Link <Link
key={item.to} key={item.to}
to={item.to} href={item.to}
onClick={() => setSearchOpen(false)} 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" 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 Link from 'next/link'
import type { Product } from '../types' import type { Product } from '@homaroma/types'
import { getCategoryImage, formatPrice } from '../types' import { getCategoryImage, formatPrice } from '@homaroma/types'
interface ProductCardProps { interface ProductCardProps {
product: Product 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="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"> <div className="h-40 md:h-64 overflow-hidden rounded-xl relative">
<img <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" className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-700"
alt={product.scent.name} alt={product.scent.name}
/> />
@@ -52,7 +52,7 @@ export default function ProductCard({ product }: ProductCardProps) {
<div className="flex-1" /> <div className="flex-1" />
<Link <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" 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 Discover
+4 -4
View File
@@ -1,12 +1,12 @@
"use client";
import { useEffect } from 'react' import { useEffect } from 'react'
import { useLocation } from 'react-router-dom' import { usePathname } from 'next/navigation'
/** /**
* Scrolls window to top on every route change. * Scrolls window to top on every route change (Next.js version).
* Place this inside <BrowserRouter> so it has access to location context.
*/ */
export default function ScrollToTop() { export default function ScrollToTop() {
const { pathname } = useLocation() const pathname = usePathname()
useEffect(() => { useEffect(() => {
window.scrollTo({ top: 0, behavior: 'instant' }) 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