From 630c7adde143c0b8118505c9fe6cf1bfcbe8788e Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Thu, 12 Mar 2026 16:00:25 +0600 Subject: [PATCH 1/2] Add coupon validation API and checkout UI Add a public storefront API endpoint to validate discount/coupon codes (src/app/api/store/[slug]/coupons/validate/route.ts). The new route verifies store slug, coupon activity/expiration/usage/minimum order, calculates percentage or fixed discounts (with caps), and returns the discount amount or appropriate errors. Update checkout page (src/app/store/[slug]/checkout/page.tsx) to support applying/removing coupons and include discount in order totals. Changes include new state/handlers to call the validate API, display errors/toasts, persist discountCode in the form payload, and subtract discount from the total. Also add a Delivery Zone radio group (inside/outside Dhaka) integrated with cart-store, update imports, and include discountAmount in the checkout submission. --- .../store/[slug]/coupons/validate/route.ts | 116 ++++++++++ src/app/store/[slug]/checkout/page.tsx | 209 +++++++++++++++++- 2 files changed, 318 insertions(+), 7 deletions(-) create mode 100644 src/app/api/store/[slug]/coupons/validate/route.ts diff --git a/src/app/api/store/[slug]/coupons/validate/route.ts b/src/app/api/store/[slug]/coupons/validate/route.ts new file mode 100644 index 00000000..cade64b8 --- /dev/null +++ b/src/app/api/store/[slug]/coupons/validate/route.ts @@ -0,0 +1,116 @@ +// src/app/api/store/[slug]/coupons/validate/route.ts +// Public storefront endpoint – validate a discount/coupon code at checkout + +import { NextRequest, NextResponse } from 'next/server'; +import prisma from '@/lib/prisma'; + +type RouteContext = { + params: Promise<{ slug: string }>; +}; + +/** + * POST /api/store/[slug]/coupons/validate + * Validates a coupon code and returns the calculated discount amount. + * No authentication required (public storefront endpoint). + * + * Body: { code: string; orderAmount: number } + * Returns: { valid: true; code; discountAmount; description; type; value } + * or { error: string } with a 4xx status + */ +export async function POST(request: NextRequest, context: RouteContext) { + try { + const { slug } = await context.params; + + const body = await request.json(); + const code: string = (body.code ?? '').toString().toUpperCase().trim(); + const orderAmount: number = Number(body.orderAmount ?? 0); + + if (!code) { + return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 }); + } + + if (orderAmount < 0) { + return NextResponse.json({ error: 'Invalid order amount' }, { status: 400 }); + } + + // Find store by slug + const store = await prisma.store.findFirst({ + where: { slug, deletedAt: null }, + select: { id: true }, + }); + + if (!store) { + return NextResponse.json({ error: 'Store not found' }, { status: 404 }); + } + + // Find active coupon for this store + const coupon = await prisma.discountCode.findFirst({ + where: { + storeId: store.id, + code, + isActive: true, + deletedAt: null, + OR: [ + { expiresAt: null }, + { expiresAt: { gt: new Date() } }, + ], + }, + }); + + if (!coupon) { + return NextResponse.json( + { error: 'Invalid or expired coupon code' }, + { status: 400 } + ); + } + + // Check max global uses + if (coupon.maxUses !== null && coupon.currentUses >= coupon.maxUses) { + return NextResponse.json( + { error: 'This coupon has reached its maximum usage limit' }, + { status: 400 } + ); + } + + // Check minimum order amount + if (coupon.minOrderAmount !== null && orderAmount < coupon.minOrderAmount) { + // Format amount for display (divide by 100 for major units) + const minDisplay = (coupon.minOrderAmount / 100).toFixed(2); + return NextResponse.json( + { error: `Minimum order amount of ৳${minDisplay} is required for this coupon` }, + { status: 400 } + ); + } + + // Calculate discount amount (values are stored in minor units / paisa) + let discountAmount: number; + + if (coupon.type === 'PERCENTAGE') { + // value is 0-100 (e.g. 10 = 10%) + discountAmount = Math.round((orderAmount * coupon.value) / 100); + // Apply cap if set + if (coupon.maxDiscountAmount !== null) { + discountAmount = Math.min(discountAmount, coupon.maxDiscountAmount); + } + } else { + // FIXED – value is minor units (paisa) + // Cannot discount more than the order amount + discountAmount = Math.min(coupon.value, orderAmount); + } + + return NextResponse.json({ + valid: true, + code: coupon.code, + discountAmount, + description: coupon.description ?? coupon.name, + type: coupon.type, + value: coupon.value, + }); + } catch (error) { + console.error('[coupon/validate] Error:', error); + return NextResponse.json( + { error: 'Failed to validate coupon. Please try again.' }, + { status: 500 } + ); + } +} diff --git a/src/app/store/[slug]/checkout/page.tsx b/src/app/store/[slug]/checkout/page.tsx index 63e840f0..fe641085 100644 --- a/src/app/store/[slug]/checkout/page.tsx +++ b/src/app/store/[slug]/checkout/page.tsx @@ -11,9 +11,9 @@ import { Label } from "@/components/ui/label"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; -import { ArrowLeft, Check, CreditCard, Loader2, Banknote, Smartphone, Building2 } from "lucide-react"; +import { ArrowLeft, Check, CreditCard, Loader2, Banknote, Smartphone, Building2, Tag, X } from "lucide-react"; import Link from "next/link"; -import { useCart } from "@/lib/stores/cart-store"; +import { useCart, DeliveryLocation } from "@/lib/stores/cart-store"; import { useStoreUrl } from "@/components/storefront/store-url-provider"; import { formatMoney } from "@/lib/money"; import { toast } from "sonner"; @@ -108,9 +108,21 @@ export default function CheckoutPage() { const removeItem = useCart((state) => state.removeItem); const getSubtotal = useCart((state) => state.getSubtotal); const getCourierTotal = useCart((state) => state.getCourierTotal); + const setDeliveryLocation = useCart((state) => state.setDeliveryLocation); + const deliveryLocation = useCart((state) => state.deliveryLocation); const [isProcessing, setIsProcessing] = useState(false); const [isValidating, setIsValidating] = useState(true); + + // Coupon / discount state + const [couponCode, setCouponCode] = useState(""); + const [couponLoading, setCouponLoading] = useState(false); + const [couponError, setCouponError] = useState(""); + const [appliedCoupon, setAppliedCoupon] = useState<{ + code: string; + discountAmount: number; + description: string; + } | null>(null); // Initialize store slug useEffect(() => { @@ -215,7 +227,53 @@ export default function CheckoutPage() { const subtotal = getSubtotal(); const tax = 0; // Bangladesh: Most items have 0% VAT const shipping = getCourierTotal(); - const total = subtotal + tax + shipping; + const discount = appliedCoupon?.discountAmount ?? 0; + const total = subtotal + tax + shipping - discount; + + // Apply coupon code via storefront API + const handleApplyCoupon = async () => { + const trimmedCode = couponCode.trim(); + if (!trimmedCode) return; + + setCouponLoading(true); + setCouponError(""); + + try { + const res = await fetch(storeApiUrl("/coupons/validate"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ code: trimmedCode, orderAmount: subtotal }), + }); + + const data = await res.json(); + + if (!res.ok) { + setCouponError(data.error || "Invalid or expired coupon code"); + setAppliedCoupon(null); + setValue("discountCode", undefined); + return; + } + + setAppliedCoupon({ + code: data.code, + discountAmount: data.discountAmount, + description: data.description, + }); + setValue("discountCode", data.code); + toast.success(`Coupon applied! You save ${formatMoney(data.discountAmount)}`); + } catch { + setCouponError("Failed to apply coupon. Please try again."); + } finally { + setCouponLoading(false); + } + }; + + const handleRemoveCoupon = () => { + setAppliedCoupon(null); + setCouponCode(""); + setCouponError(""); + setValue("discountCode", undefined); + }; const onSubmit = async (data: CheckoutFormData) => { setIsProcessing(true); @@ -263,6 +321,7 @@ export default function CheckoutPage() { subtotal, taxAmount: tax, shippingAmount: shipping, + discountAmount: discount, totalAmount: total, paymentMethod: data.paymentMethod, discountCode: data.discountCode, @@ -571,6 +630,68 @@ export default function CheckoutPage() { + {/* Delivery Zone – required for shipping cost calculation */} + + + + + 3 + + Delivery Zone + + + Select your delivery area so we can calculate the shipping charge + + + + + setDeliveryLocation(val as DeliveryLocation) + } + className="space-y-3" + > +
+ + +
+ +
+ + +
+
+ + {!deliveryLocation && ( +

+ ⚠ Please select a delivery zone to see the shipping charge. +

+ )} +
+
+ {/* Billing Address (Conditional) */} {!billingSameAsShipping && ( @@ -665,7 +786,7 @@ export default function CheckoutPage() { - 3 + 4 Payment Method @@ -765,6 +886,62 @@ export default function CheckoutPage() { + {/* Coupon Code */} + {appliedCoupon ? ( +
+
+ +
+

{appliedCoupon.code}

+

{appliedCoupon.description}

+
+
+ +
+ ) : ( +
+
+ { + setCouponCode(e.target.value.toUpperCase()); + setCouponError(""); + }} + onKeyDown={(e) => e.key === "Enter" && (e.preventDefault(), handleApplyCoupon())} + className="h-9 text-sm" + disabled={couponLoading} + /> + +
+ {couponError && ( +

{couponError}

+ )} +
+ )} + + + {/* Price Breakdown */}
@@ -772,15 +949,33 @@ export default function CheckoutPage() { {formatMoney(subtotal)}
- Shipping + + Shipping + {deliveryLocation && ( + + ({deliveryLocation === "inside_dhaka" ? "Inside Dhaka" : "Outside Dhaka"}) + + )} + - {shipping === 0 ? ( + {!deliveryLocation ? ( + Select zone ↑ + ) : shipping === 0 ? ( FREE ) : ( - `${formatMoney(shipping)}` + formatMoney(shipping) )}
+ {discount > 0 && ( +
+ + + Discount ({appliedCoupon?.code}) + + -{formatMoney(discount)} +
+ )}
Tax (estimated) {formatMoney(tax)} From 2ef7738201307e6609e6ebc83b54ad251fc96388 Mon Sep 17 00:00:00 2001 From: Rafiqul Islam Date: Thu, 12 Mar 2026 16:09:11 +0600 Subject: [PATCH 2/2] Make state field optional in checkout form Mark shippingState as optional in the checkout Zod schema and update the custom refine check to no longer require billingState when billing differs from shipping. Also update the shipping and billing State/Province labels to indicate the field is optional. This allows addresses for countries without states/provinces and keeps validation consistent with the UI. --- src/app/store/[slug]/checkout/page.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/app/store/[slug]/checkout/page.tsx b/src/app/store/[slug]/checkout/page.tsx index fe641085..168cd7e4 100644 --- a/src/app/store/[slug]/checkout/page.tsx +++ b/src/app/store/[slug]/checkout/page.tsx @@ -49,7 +49,7 @@ const checkoutSchema = z.object({ // Shipping Address shippingAddress: z.string().min(5, "Address is required"), shippingCity: z.string().min(1, "City is required"), - shippingState: z.string().min(1, "State/Province is required"), + shippingState: z.string().optional(), shippingPostalCode: z.string().min(3, "Postal code is required"), shippingCountry: z.string().min(2, "Country is required"), @@ -69,12 +69,11 @@ const checkoutSchema = z.object({ // Discount code (optional) discountCode: z.string().optional(), }).refine((data) => { - // If billing is different, require billing fields + // If billing is different, require billing fields (except state which is optional) if (!data.billingSameAsShipping) { return !!( data.billingAddress && data.billingCity && - data.billingState && data.billingPostalCode && data.billingCountry ); @@ -568,7 +567,7 @@ export default function CheckoutPage() { )}
- +
- +