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

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

No files matched your search

+384
View File
@@ -0,0 +1,384 @@
import { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import Navbar from '../components/Navbar'
import Footer from '../components/Footer'
import { API_BASE, formatPrice, type Category } from '../types'
import { useCartStore } from '../store/cartStore'
import { useAuthStore } from '../store/authStore'
import { Loader2 } from 'lucide-react'
export default function CheckoutPage() {
const navigate = useNavigate()
const { items, getCartTotal, clearCart } = useCartStore()
const { customer } = useAuthStore()
const [categories, setCategories] = useState<Category[]>([])
const [shippingFee, setShippingFee] = useState<number>(35)
const [loading, setLoading] = useState(false)
const [loadingSettings, setLoadingSettings] = useState(true)
const [couponCode, setCouponCode] = useState('')
const [appliedCoupon, setAppliedCoupon] = useState<string | null>(null)
const [discountAmount, setDiscountAmount] = useState<number>(0)
const [isFreeShipping, setIsFreeShipping] = useState<boolean>(false)
const [couponLoading, setCouponLoading] = useState(false)
const [couponError, setCouponError] = useState('')
const [formData, setFormData] = useState({
name: '',
email: '',
phone: '',
address: ''
})
// Prefill form with LINE customer data
useEffect(() => {
if (customer) {
setFormData(prev => ({
...prev,
name: prev.name || customer.displayName || '',
email: prev.email || customer.email || '',
phone: prev.phone || customer.phone || ''
}))
}
}, [customer])
// Load Settings (for dynamic shipping fee)
useEffect(() => {
fetch(`${API_BASE}/settings`)
.then(res => res.json())
.then(data => {
if (data.shippingFee !== undefined) setShippingFee(data.shippingFee)
})
.catch(err => console.error("Could not load settings:", err))
.finally(() => setLoadingSettings(false))
fetch(`${API_BASE}/categories`)
.then(res => res.json())
.then(setCategories)
// Check if there's an active coupon saved in localStorage
const savedCoupon = localStorage.getItem('irelax_active_coupon')
if (savedCoupon) {
setCouponCode(savedCoupon)
// We will trigger handleApplyCoupon but we need to wait for items/subtotal to be ready.
// So we'll handle this in another effect or let the user click apply if needed.
// Better yet, we can validate it immediately if subtotal is available.
}
}, [])
// Kick out if cart empty
useEffect(() => {
if (items.length === 0) {
navigate('/shop')
}
}, [items, navigate])
const subtotal = getCartTotal()
const effectiveShippingFee = isFreeShipping ? 0 : shippingFee
const total = Math.max(0, subtotal + effectiveShippingFee - discountAmount)
// Auto-apply saved coupon if subtotal is ready
useEffect(() => {
const savedCoupon = localStorage.getItem('irelax_active_coupon')
if (savedCoupon && subtotal > 0 && !appliedCoupon && !couponError && !couponLoading) {
setCouponCode(savedCoupon)
// trigger fetch to validate
fetch(`${API_BASE}/coupons/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: savedCoupon, cartTotal: subtotal })
})
.then(r => r.json())
.then(data => {
if (data.valid) {
setAppliedCoupon(data.coupon.code)
setDiscountAmount(data.discountAmount)
setIsFreeShipping(data.isFreeShipping)
} else {
localStorage.removeItem('irelax_active_coupon') // Remove invalid
}
})
.catch(console.error)
}
}, [subtotal, appliedCoupon])
// Re-validate applied coupon if subtotal changes
useEffect(() => {
if (appliedCoupon && subtotal > 0) {
fetch(`${API_BASE}/coupons/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: appliedCoupon, cartTotal: subtotal })
})
.then(r => r.json())
.then(data => {
if (data.valid) {
setDiscountAmount(data.discountAmount)
setIsFreeShipping(data.isFreeShipping)
} else {
removeCoupon()
setCouponError(data.error || 'Coupon is no longer valid for this cart')
}
})
}
}, [subtotal, appliedCoupon])
const handleApplyCoupon = async () => {
if (!couponCode.trim()) return
setCouponLoading(true)
setCouponError('')
try {
const res = await fetch(`${API_BASE}/coupons/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: couponCode, cartTotal: subtotal })
})
const data = await res.json()
if (res.ok && data.valid) {
setAppliedCoupon(data.coupon.code)
setDiscountAmount(data.discountAmount)
setIsFreeShipping(data.isFreeShipping)
} else {
setCouponError(data.error || 'Invalid coupon')
setAppliedCoupon(null)
setDiscountAmount(0)
setIsFreeShipping(false)
}
} catch (err) {
setCouponError('Error validating coupon')
} finally {
setCouponLoading(false)
}
}
const removeCoupon = () => {
setAppliedCoupon(null)
setCouponCode('')
setDiscountAmount(0)
setIsFreeShipping(false)
setCouponError('')
localStorage.removeItem('irelax_active_coupon')
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
try {
const orderData = {
customerId: customer ? customer.id : null,
customerName: formData.name,
customerEmail: formData.email,
customerPhone: formData.phone,
shippingAddress: formData.address,
totalAmount: total,
shippingFee: effectiveShippingFee,
couponCode: appliedCoupon || undefined,
discountAmount: discountAmount || undefined,
items: items.map(item => ({
productId: item.productId,
variantId: item.variantId,
quantity: item.quantity,
priceAtPurchase: item.price,
productName: item.productName,
variantSize: item.variantSize,
productImage: item.productImage
}))
}
const res = await fetch(`${API_BASE}/orders`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orderData)
})
const responseData = await res.json()
if (res.ok) {
clearCart()
const existingStr = localStorage.getItem('irelax_local_orders')
const existing = existingStr ? JSON.parse(existingStr) : []
if (!existing.includes(responseData.id)) {
localStorage.setItem('irelax_local_orders', JSON.stringify([responseData.id, ...existing]))
}
navigate(`/order/${responseData.id}`)
} else {
alert(responseData.error || 'Failed to place order')
}
} catch (err) {
console.error(err)
alert('An error occurred. Please try again.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen">
<Navbar categories={categories} />
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24">
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
<div className="flex flex-col lg:flex-row gap-12">
{/* Form Section */}
<div className="flex-1 space-y-8">
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 space-y-6">
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4">Shipping Information</h2>
<div className="grid md:grid-cols-2 gap-6">
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Full Name *</label>
<input
type="text"
required
value={formData.name}
onChange={e => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
placeholder="Jane Doe"
/>
</div>
<div className="space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Phone Number *</label>
<input
type="tel"
required
value={formData.phone}
onChange={e => setFormData({ ...formData, phone: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
placeholder="0812345678"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Email Address *</label>
<input
type="email"
required
value={formData.email}
onChange={e => setFormData({ ...formData, email: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all"
placeholder="jane@example.com"
/>
</div>
<div className="md:col-span-2 space-y-2">
<label className="text-xs uppercase tracking-widest text-slate-400">Shipping Address *</label>
<textarea
required
rows={3}
value={formData.address}
onChange={e => setFormData({ ...formData, address: e.target.value })}
className="w-full bg-night-950 border border-white/10 rounded-lg p-3 text-white focus:border-cyan-glow focus:ring-1 focus:ring-cyan-glow outline-none transition-all resize-none"
placeholder="123 Serenity Street..."
/>
</div>
</div>
</div>
</form>
</div>
{/* Summary Section */}
<div className="lg:w-96 space-y-6">
<div className="bg-night-900/50 p-8 rounded-2xl border border-white/5 sticky top-32">
<h2 className="text-xl font-bold uppercase tracking-widest text-cyan-glow border-b border-white/10 pb-4 mb-6">Order Summary</h2>
{/* Coupon Section */}
<div className="mb-6 pb-6 border-b border-white/10">
{!appliedCoupon ? (
<div className="space-y-2">
<label className="text-[10px] uppercase font-bold tracking-widest text-slate-500">Discount Code</label>
<div className="flex gap-2">
<input
type="text"
value={couponCode}
onChange={e => setCouponCode(e.target.value.toUpperCase())}
placeholder="e.g. SUMMER2026"
className="flex-1 bg-night-950 border border-white/10 rounded-lg px-3 text-sm text-white focus:border-cyan-glow outline-none uppercase tracking-widest"
/>
<button
type="button"
onClick={handleApplyCoupon}
disabled={couponLoading || !couponCode.trim()}
className="px-4 bg-white/5 hover:bg-white/10 border border-white/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all"
>
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
</button>
</div>
{couponError && <p className="text-xs text-rose-500 mt-1">{couponError}</p>}
</div>
) : (
<div className="flex items-center justify-between bg-cyan-glow/10 border border-cyan-glow/20 rounded-lg px-4 py-3">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-cyan-glow" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-bold tracking-widest text-cyan-glow">{appliedCoupon}</span>
</div>
<button type="button" onClick={removeCoupon} className="text-xs text-slate-400 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
</div>
)}
</div>
<div className="space-y-4 mb-6">
{items.map(item => (
<div key={item.cartItemId} className="flex justify-between items-start gap-4 text-sm">
<div className="flex-1">
<p className="font-bold">{item.productName}</p>
<p className="text-slate-400 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
</div>
<p className="font-medium text-cyan-glow">{formatPrice(item.price * item.quantity)}</p>
</div>
))}
</div>
<div className="border-t border-white/10 pt-4 space-y-3 font-medium">
<div className="flex justify-between text-sm text-slate-300">
<span>Subtotal</span>
<span>{formatPrice(subtotal)}</span>
</div>
<div className="flex justify-between text-sm text-slate-300">
<span>Shipping Fee</span>
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
<span className={isFreeShipping ? "line-through text-slate-500" : ""}>{formatPrice(shippingFee)}</span>
}
</div>
{isFreeShipping && (
<div className="flex justify-between text-sm text-emerald-400">
<span>Shipping Discount</span>
<span>- {formatPrice(shippingFee)}</span>
</div>
)}
{discountAmount > 0 && (
<div className="flex justify-between text-sm text-cyan-glow">
<span>Discount</span>
<span>- {formatPrice(discountAmount)}</span>
</div>
)}
<div className="border-t border-white/10 pt-3 mt-3 flex justify-between text-lg font-bold">
<span>Total</span>
<span className="text-cyan-glow">{formatPrice(total)}</span>
</div>
</div>
<button
form="checkout-form"
type="submit"
disabled={loading || loadingSettings}
className="w-full mt-8 py-4 bg-white text-night-900 font-bold rounded-lg tracking-widest uppercase hover:bg-cyan-glow hover:shadow-[0_0_25px_rgba(102,252,241,0.4)] transition-all duration-500 disabled:opacity-50 disabled:cursor-not-allowed flex justify-center items-center gap-2"
>
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm Order'}
</button>
</div>
</div>
</div>
</main>
<Footer categories={categories} />
</div>
)
}