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() { )}
+ ⚠ Please select a delivery zone to see the shipping charge. +
+ )} +{appliedCoupon.code}
+{appliedCoupon.description}
+{couponError}
+ )} +