feat(spa): integrate 3-level promotions and loyalty points redemption in checkout
This commit is contained in:
1 parent
f72876587e
commit
8cdecfd7de
2 files changed
+112
-4
No files matched your search
@@ -24,6 +24,11 @@ export default function CheckoutPage() {
|
|||||||
const [couponLoading, setCouponLoading] = useState(false)
|
const [couponLoading, setCouponLoading] = useState(false)
|
||||||
const [couponError, setCouponError] = useState('')
|
const [couponError, setCouponError] = useState('')
|
||||||
|
|
||||||
|
const [activePromos, setActivePromos] = useState<any[]>([])
|
||||||
|
const [customerPoints, setCustomerPoints] = useState(0)
|
||||||
|
const [usePoints, setUsePoints] = useState(false)
|
||||||
|
const [pointsToUse, setPointsToUse] = useState(0)
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -57,6 +62,13 @@ export default function CheckoutPage() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.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])
|
}, [customer])
|
||||||
|
|
||||||
@@ -74,6 +86,13 @@ export default function CheckoutPage() {
|
|||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then(setCategories)
|
.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
|
// Check if there's an active coupon saved in localStorage
|
||||||
const savedCoupon = localStorage.getItem('homaroma_active_coupon')
|
const savedCoupon = localStorage.getItem('homaroma_active_coupon')
|
||||||
if (savedCoupon) {
|
if (savedCoupon) {
|
||||||
@@ -92,8 +111,33 @@ export default function CheckoutPage() {
|
|||||||
}, [items, navigate])
|
}, [items, navigate])
|
||||||
|
|
||||||
const subtotal = getCartTotal()
|
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
|
const effectiveShippingFee = isFreeShipping ? 0 : shippingFee
|
||||||
const total = Math.max(0, subtotal + effectiveShippingFee - discountAmount)
|
|
||||||
|
// 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
|
// Auto-apply saved coupon if subtotal is ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -126,7 +170,7 @@ export default function CheckoutPage() {
|
|||||||
fetch(`${API_BASE}/coupons/validate`, {
|
fetch(`${API_BASE}/coupons/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code: appliedCoupon, cartTotal: subtotal })
|
body: JSON.stringify({ code: appliedCoupon, cartTotal: subtotalAfterL2 })
|
||||||
})
|
})
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
@@ -150,7 +194,7 @@ export default function CheckoutPage() {
|
|||||||
const res = await fetch(`${API_BASE}/coupons/validate`, {
|
const res = await fetch(`${API_BASE}/coupons/validate`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ code: couponCode, cartTotal: subtotal })
|
body: JSON.stringify({ code: couponCode, cartTotal: subtotalAfterL2 })
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
|
|
||||||
@@ -193,8 +237,15 @@ export default function CheckoutPage() {
|
|||||||
shippingAddress: formData.address,
|
shippingAddress: formData.address,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
shippingFee: effectiveShippingFee,
|
shippingFee: effectiveShippingFee,
|
||||||
|
subtotal,
|
||||||
|
promoLevel1Name: promoLevel1?.name || null,
|
||||||
|
promoLevel1Disc: promoLevel1Disc || 0,
|
||||||
|
promoLevel2Name: promoLevel2?.name || null,
|
||||||
|
promoLevel2Disc: promoLevel2Disc || 0,
|
||||||
couponCode: appliedCoupon || undefined,
|
couponCode: appliedCoupon || undefined,
|
||||||
discountAmount: discountAmount || undefined,
|
discountAmount: discountAmount || undefined,
|
||||||
|
pointsUsed: pointsDiscount > 0 ? pointsDiscount : 0,
|
||||||
|
pointsDiscount: pointsDiscount > 0 ? pointsDiscount : 0,
|
||||||
items: items.map(item => ({
|
items: items.map(item => ({
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
variantId: item.variantId,
|
variantId: item.variantId,
|
||||||
@@ -346,6 +397,44 @@ export default function CheckoutPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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">
|
<div className="space-y-4 mb-6">
|
||||||
{items.map(item => (
|
{items.map(item => (
|
||||||
@@ -376,12 +465,30 @@ export default function CheckoutPage() {
|
|||||||
<span>- {formatPrice(shippingFee)}</span>
|
<span>- {formatPrice(shippingFee)}</span>
|
||||||
</div>
|
</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 && (
|
{discountAmount > 0 && (
|
||||||
<div className="flex justify-between text-sm text-rose-500">
|
<div className="flex justify-between text-sm text-rose-500">
|
||||||
<span>Discount</span>
|
<span>Coupon ({appliedCoupon})</span>
|
||||||
<span>- {formatPrice(discountAmount)}</span>
|
<span>- {formatPrice(discountAmount)}</span>
|
||||||
</div>
|
</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">
|
<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>Total</span>
|
||||||
<span className="text-rose-500">{formatPrice(total)}</span>
|
<span className="text-rose-500">{formatPrice(total)}</span>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export interface Customer {
|
|||||||
pictureUrl: string | null;
|
pictureUrl: string | null;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
|
points?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
|
|||||||
Reference in new issue
Block a user