537 lines
24 KiB
TypeScript
537 lines
24 KiB
TypeScript
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 [isSummaryExpanded, setIsSummaryExpanded] = useState(true)
|
|
|
|
const [activePromos, setActivePromos] = useState<any[]>([])
|
|
const [customerPoints, setCustomerPoints] = useState(0)
|
|
const [usePoints, setUsePoints] = useState(false)
|
|
const [pointsToUse, setPointsToUse] = useState(0)
|
|
|
|
const [formData, setFormData] = useState({
|
|
name: '',
|
|
email: '',
|
|
phone: '',
|
|
address: ''
|
|
})
|
|
|
|
// Prefill form with LINE customer data and last used address
|
|
useEffect(() => {
|
|
if (customer) {
|
|
setFormData(prev => ({
|
|
...prev,
|
|
name: prev.name || customer.displayName || '',
|
|
email: prev.email || customer.email || '',
|
|
phone: prev.phone || customer.phone || ''
|
|
}))
|
|
|
|
// Fetch latest order to pre-fill address acting as an automatic Address Book
|
|
fetch(`${API_BASE}/orders/customer/${customer.id}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (Array.isArray(data) && data.length > 0) {
|
|
const lastOrder = data[0];
|
|
setFormData(prev => ({
|
|
...prev,
|
|
name: prev.name || lastOrder.customerName || '',
|
|
phone: prev.phone || lastOrder.customerPhone || '',
|
|
email: prev.email || lastOrder.customerEmail || '',
|
|
address: prev.address || lastOrder.shippingAddress || ''
|
|
}))
|
|
}
|
|
})
|
|
.catch(console.error)
|
|
|
|
fetch(`${API_BASE}/customers/${customer.id}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data && data.points) setCustomerPoints(data.points)
|
|
})
|
|
.catch(console.error)
|
|
}
|
|
}, [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)
|
|
|
|
fetch(`${API_BASE}/promotions/active`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (Array.isArray(data)) setActivePromos(data)
|
|
})
|
|
.catch(console.error)
|
|
|
|
// Check if there's an active coupon saved in localStorage
|
|
const savedCoupon = localStorage.getItem('homaroma_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()
|
|
|
|
// Calculate Level 1 Promo
|
|
const promoLevel1 = activePromos.find(p => p.level === 1 && subtotal >= p.minPurchase)
|
|
const promoLevel1Disc = promoLevel1
|
|
? (promoLevel1.type === 'PERCENTAGE' ? subtotal * (promoLevel1.value / 100) : promoLevel1.value)
|
|
: 0
|
|
|
|
const subtotalAfterL1 = Math.max(0, subtotal - promoLevel1Disc)
|
|
|
|
// Calculate Level 2 Promo
|
|
const promoLevel2 = activePromos.find(p => p.level === 2 && subtotalAfterL1 >= p.minPurchase)
|
|
const promoLevel2Disc = promoLevel2
|
|
? (promoLevel2.type === 'PERCENTAGE' ? subtotalAfterL1 * (promoLevel2.value / 100) : promoLevel2.value)
|
|
: 0
|
|
|
|
const subtotalAfterL2 = Math.max(0, subtotalAfterL1 - promoLevel2Disc)
|
|
|
|
const effectiveShippingFee = isFreeShipping ? 0 : shippingFee
|
|
|
|
// After Coupon (L3)
|
|
const subtotalAfterL3 = Math.max(0, subtotalAfterL2 - discountAmount)
|
|
|
|
// Points Deduction (Max points to use = Math.min(customerPoints, subtotalAfterL3))
|
|
const maxPointsAllowed = Math.floor(subtotalAfterL3)
|
|
const pointsDiscount = usePoints ? Math.min(pointsToUse, maxPointsAllowed) : 0
|
|
|
|
const total = Math.max(0, subtotalAfterL3 - pointsDiscount + effectiveShippingFee)
|
|
|
|
// Auto-apply saved coupon if subtotal is ready
|
|
useEffect(() => {
|
|
const savedCoupon = localStorage.getItem('homaroma_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('homaroma_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: subtotalAfterL2 })
|
|
})
|
|
.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: subtotalAfterL2 })
|
|
})
|
|
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('homaroma_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,
|
|
subtotal,
|
|
promoLevel1Name: promoLevel1?.name || null,
|
|
promoLevel1Disc: promoLevel1Disc || 0,
|
|
promoLevel2Name: promoLevel2?.name || null,
|
|
promoLevel2Disc: promoLevel2Disc || 0,
|
|
couponCode: appliedCoupon || undefined,
|
|
discountAmount: discountAmount || undefined,
|
|
pointsUsed: pointsDiscount > 0 ? pointsDiscount : 0,
|
|
pointsDiscount: pointsDiscount > 0 ? pointsDiscount : 0,
|
|
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('homaroma_local_orders')
|
|
const existing = existingStr ? JSON.parse(existingStr) : []
|
|
if (!existing.includes(responseData.id)) {
|
|
localStorage.setItem('homaroma_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 bg-pop-bg">
|
|
<Navbar categories={categories} />
|
|
|
|
<main className="max-w-6xl mx-auto px-6 pt-32 pb-24 text-pop-black">
|
|
<h1 className="text-4xl font-bold mb-12">Checkout</h1>
|
|
|
|
<div className="flex flex-col lg:flex-row gap-12 relative">
|
|
{/* Form Section */}
|
|
<div className="flex-1 space-y-8 order-2 lg:order-1">
|
|
<form id="checkout-form" onSubmit={handleSubmit} className="space-y-6">
|
|
<div className="bg-white p-8 rounded-2xl border border-spa-stone/10 space-y-6 shadow-sm">
|
|
<h2 className="text-xl font-bold uppercase tracking-widest text-spa-forest border-b border-spa-stone/10 pb-4">Shipping Information</h2>
|
|
|
|
<div className="grid md:grid-cols-2 gap-6">
|
|
<div className="space-y-2">
|
|
<label htmlFor="checkout-name" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Full Name *</label>
|
|
<input
|
|
id="checkout-name"
|
|
type="text"
|
|
required
|
|
value={formData.name}
|
|
onChange={e => setFormData({ ...formData, name: e.target.value })}
|
|
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
|
|
placeholder="Jane Doe"
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<label htmlFor="checkout-phone" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Phone Number *</label>
|
|
<input
|
|
id="checkout-phone"
|
|
type="tel"
|
|
required
|
|
pattern="[0-9]{9,10}"
|
|
title="Please enter a valid phone number (9-10 digits)"
|
|
value={formData.phone}
|
|
onChange={e => setFormData({ ...formData, phone: e.target.value })}
|
|
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
|
|
placeholder="0812345678"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label htmlFor="checkout-email" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Email Address *</label>
|
|
<input
|
|
id="checkout-email"
|
|
type="email"
|
|
required
|
|
value={formData.email}
|
|
onChange={e => setFormData({ ...formData, email: e.target.value })}
|
|
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all placeholder:text-spa-stone/50"
|
|
placeholder="jane@example.com"
|
|
/>
|
|
</div>
|
|
<div className="md:col-span-2 space-y-2">
|
|
<label htmlFor="checkout-address" className="text-xs uppercase tracking-widest text-spa-stone/80 font-bold">Shipping Address *</label>
|
|
<textarea
|
|
id="checkout-address"
|
|
required
|
|
rows={3}
|
|
value={formData.address}
|
|
onChange={e => setFormData({ ...formData, address: e.target.value })}
|
|
className="w-full bg-spa-light border border-spa-stone/20 rounded-lg p-3 text-pop-black focus:border-spa-forest focus:ring-1 focus:ring-spa-forest outline-none transition-all resize-none placeholder:text-spa-stone/50"
|
|
placeholder="123 Serenity Street..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Mobile Submit Button */}
|
|
<button
|
|
type="submit"
|
|
disabled={loading || loadingSettings}
|
|
className="w-full py-4 bg-spa-forest text-white font-bold rounded-xl tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed flex lg:hidden justify-center items-center gap-2 mt-4"
|
|
>
|
|
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : 'Confirm Order'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
{/* Summary Section */}
|
|
<div className="lg:w-96 space-y-6 order-1 lg:order-2">
|
|
<div className="bg-white p-6 lg:p-8 rounded-2xl border border-spa-stone/10 shadow-sm sticky top-32 transition-all">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsSummaryExpanded(!isSummaryExpanded)}
|
|
className="w-full flex items-center justify-between text-xl font-bold uppercase tracking-widest text-spa-forest pb-4 border-b border-spa-stone/10 mb-6 lg:pointer-events-none"
|
|
>
|
|
<span>Order Summary</span>
|
|
<span className="lg:hidden">
|
|
<svg className={`w-5 h-5 transform transition-transform ${isSummaryExpanded ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path></svg>
|
|
</span>
|
|
</button>
|
|
|
|
<div className={`overflow-hidden transition-all duration-300 ${isSummaryExpanded ? 'max-h-[2000px] opacity-100' : 'max-h-0 opacity-0 lg:max-h-[2000px] lg:opacity-100'}`}>
|
|
{/* Coupon Section */}
|
|
<div className="mb-6 pb-6 border-b border-spa-stone/10">
|
|
{!appliedCoupon ? (
|
|
<div className="space-y-2">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80">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-spa-light border border-spa-stone/20 rounded-lg px-3 text-sm text-pop-black focus:border-spa-forest outline-none uppercase tracking-widest placeholder:text-spa-stone/50"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={handleApplyCoupon}
|
|
disabled={couponLoading || !couponCode.trim()}
|
|
className="px-4 bg-spa-stone/10 hover:bg-spa-stone/20 border border-spa-stone/10 rounded-lg text-xs font-bold uppercase tracking-widest disabled:opacity-50 transition-all text-pop-black"
|
|
>
|
|
{couponLoading ? <Loader2 className="w-4 h-4 animate-spin" /> : 'Apply'}
|
|
</button>
|
|
</div>
|
|
{couponError && <p className="text-xs text-rose-500 mt-1 font-medium">{couponError}</p>}
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between bg-spa-sage/30 border border-spa-forest/20 rounded-lg px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<svg className="w-4 h-4 text-spa-forest" 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-spa-forest">{appliedCoupon}</span>
|
|
</div>
|
|
<button type="button" onClick={removeCoupon} className="text-xs text-spa-stone/80 hover:text-rose-500 uppercase tracking-widest font-bold">Remove</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Points Section */}
|
|
{customerPoints > 0 && (
|
|
<div className="mb-6 pb-6 border-b border-spa-stone/10 space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-[10px] uppercase font-bold tracking-widest text-spa-stone/80 flex items-center gap-2">
|
|
<span className="text-purple-500 text-sm">⭐️</span> Use Loyalty Points
|
|
</label>
|
|
<span className="text-xs font-bold text-spa-forest">{customerPoints} Points Available</span>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<input
|
|
type="checkbox"
|
|
id="use-points"
|
|
checked={usePoints}
|
|
onChange={e => {
|
|
setUsePoints(e.target.checked)
|
|
if (e.target.checked) setPointsToUse(Math.min(customerPoints, maxPointsAllowed))
|
|
}}
|
|
className="w-4 h-4 text-spa-forest border-spa-stone/20 rounded focus:ring-spa-forest"
|
|
/>
|
|
<label htmlFor="use-points" className="text-sm text-pop-black font-medium">Apply points discount</label>
|
|
</div>
|
|
{usePoints && (
|
|
<div className="flex items-center gap-2 mt-2">
|
|
<input
|
|
type="number"
|
|
min="1"
|
|
max={Math.min(customerPoints, maxPointsAllowed)}
|
|
value={pointsToUse}
|
|
onChange={e => setPointsToUse(Math.min(Number(e.target.value), customerPoints, maxPointsAllowed))}
|
|
className="w-24 bg-spa-light border border-spa-stone/20 rounded-lg px-3 py-1.5 text-sm text-pop-black focus:border-spa-forest outline-none"
|
|
/>
|
|
<span className="text-xs text-spa-stone/80 font-bold uppercase tracking-widest">Points (-฿{pointsToUse})</span>
|
|
</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 text-pop-black">
|
|
<div className="flex-1">
|
|
<p className="font-bold">{item.productName}</p>
|
|
<p className="text-spa-stone/80 text-xs mt-0.5">{item.variantSize} x {item.quantity}</p>
|
|
</div>
|
|
<p className="font-bold text-rose-500">{formatPrice(item.price * item.quantity)}</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="border-t border-spa-stone/10 pt-4 space-y-3 font-medium text-pop-black">
|
|
<div className="flex justify-between text-sm text-spa-stone">
|
|
<span>Subtotal</span>
|
|
<span>{formatPrice(subtotal)}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm text-spa-stone">
|
|
<span>Shipping Fee</span>
|
|
{loadingSettings ? <Loader2 className="w-4 h-4 animate-spin" /> :
|
|
<span className={isFreeShipping ? "line-through text-spa-stone/50" : ""}>{formatPrice(shippingFee)}</span>
|
|
}
|
|
</div>
|
|
{isFreeShipping && (
|
|
<div className="flex justify-between text-sm text-spa-forest">
|
|
<span>Shipping Discount</span>
|
|
<span>- {formatPrice(shippingFee)}</span>
|
|
</div>
|
|
)}
|
|
{promoLevel1Disc > 0 && (
|
|
<div className="flex justify-between text-sm text-spa-forest">
|
|
<span>{promoLevel1?.name}</span>
|
|
<span>- {formatPrice(promoLevel1Disc)}</span>
|
|
</div>
|
|
)}
|
|
{promoLevel2Disc > 0 && (
|
|
<div className="flex justify-between text-sm text-spa-forest">
|
|
<span>{promoLevel2?.name}</span>
|
|
<span>- {formatPrice(promoLevel2Disc)}</span>
|
|
</div>
|
|
)}
|
|
{discountAmount > 0 && (
|
|
<div className="flex justify-between text-sm text-rose-500">
|
|
<span>Coupon ({appliedCoupon})</span>
|
|
<span>- {formatPrice(discountAmount)}</span>
|
|
</div>
|
|
)}
|
|
{pointsDiscount > 0 && (
|
|
<div className="flex justify-between text-sm text-purple-500">
|
|
<span>Points Redeemed</span>
|
|
<span>- {formatPrice(pointsDiscount)}</span>
|
|
</div>
|
|
)}
|
|
<div className="border-t border-spa-stone/10 pt-3 mt-3 flex justify-between text-lg font-bold text-pop-black">
|
|
<span>Total</span>
|
|
<span className="text-rose-500">{formatPrice(total)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
</div> {/* End Accordion Content */}
|
|
|
|
<button
|
|
form="checkout-form"
|
|
type="submit"
|
|
disabled={loading || loadingSettings}
|
|
className="w-full mt-8 py-4 bg-spa-forest text-white font-bold rounded-xl tracking-widest uppercase hover:bg-spa-stone hover:shadow-lg transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed hidden lg: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>
|
|
)
|
|
}
|