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..168cd7e4 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"; @@ -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 ); @@ -108,9 +107,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 +226,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 +320,7 @@ export default function CheckoutPage() { subtotal, taxAmount: tax, shippingAmount: shipping, + discountAmount: discount, totalAmount: total, paymentMethod: data.paymentMethod, discountCode: data.discountCode, @@ -509,7 +567,7 @@ 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 && ( @@ -609,7 +729,7 @@ export default function CheckoutPage() { )}
- + - 3 + 4 Payment Method @@ -765,6 +885,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 +948,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)}