Rename ssr to website, website to spa, add spa nginx config
This commit is contained in:
1 parent
fc6bf0218e
commit
8514342c2f
103 files changed
+801
-768
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
VITE_LIFF_ID="2009944751-5IYZT5kH"
|
||||||
|
VITE_API_URL="https://api.homaroma.shop/api"
|
||||||
@@ -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
|
||||||
@@ -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.
@@ -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.
File renamed without changes.
@@ -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
|
||||||
@@ -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.
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.
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
File renamed without changes.
File renamed without changes.
@@ -1,2 +0,0 @@
|
|||||||
# Production - ชี้ไป Backend จริงบน server
|
|
||||||
NEXT_PUBLIC_API_URL=https://api.homaroma.shop/api
|
|
||||||
@@ -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
|
|
||||||
@@ -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.
|
|
||||||
@@ -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"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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"]
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
@@ -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">
|
<button
|
||||||
<span className="text-xs text-slate-500">{formatPrice(item.price)}</span>
|
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
||||||
<span className="text-xs text-slate-600">×</span>
|
className="p-1 hover:text-cyan-glow transition-colors"
|
||||||
<span className="text-xs text-slate-500">{item.quantity}</span>
|
>
|
||||||
<span className="text-xs text-slate-600">=</span>
|
<Minus className="w-4 h-4" />
|
||||||
<span className="text-sm font-bold text-cyan-glow">{formatPrice(item.price * item.quantity)}</span>
|
</button>
|
||||||
</div>
|
<span className="w-4 text-center text-xs font-bold">{item.quantity}</span>
|
||||||
|
<button
|
||||||
{/* Quantity controls */}
|
onClick={() => updateQuantity(item.cartItemId, item.quantity + 1)}
|
||||||
<div className="flex items-center gap-2 mt-1">
|
className="p-1 hover:text-cyan-glow transition-colors"
|
||||||
<div className="flex items-center gap-2 bg-night-950/70 rounded-lg border border-white/5">
|
>
|
||||||
<button
|
<Plus className="w-4 h-4" />
|
||||||
onClick={() => updateQuantity(item.cartItemId, item.quantity - 1)}
|
</button>
|
||||||
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>
|
</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>
|
||||||
|
<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>
|
||||||
</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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
File renamed without changes.
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user