Skip to content

Checkout22#342

Merged
rafiqul4 merged 2 commits intomainfrom
checkout22
Mar 12, 2026
Merged

Checkout22#342
rafiqul4 merged 2 commits intomainfrom
checkout22

Conversation

@rafiqul4
Copy link
Collaborator

This pull request introduces a public coupon code validation API endpoint and adds full coupon/discount code support to the checkout page, along with improvements to the shipping and address forms. The main changes include backend support for coupon validation, frontend logic for applying and displaying coupons, UI updates for delivery zone selection, and making the state/province fields optional.

Coupon/Discount Code Support:

Shipping & Delivery Zone Improvements:

Checkout Form Enhancements:

Other UI and Code Updates:

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.
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.
Copilot AI review requested due to automatic review settings March 12, 2026 10:13
@github-project-automation github-project-automation bot moved this to Backlog in StormCom Mar 12, 2026
@vercel
Copy link

vercel bot commented Mar 12, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
stormcomui Ready Ready Preview, Comment Mar 12, 2026 10:14am

@rafiqul4 rafiqul4 merged commit 5a7917b into main Mar 12, 2026
7 checks passed
@rafiqul4 rafiqul4 deleted the checkout22 branch March 12, 2026 10:17
@github-project-automation github-project-automation bot moved this from Backlog to Done in StormCom Mar 12, 2026
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds coupon/discount support to the public storefront checkout flow by introducing a coupon validation endpoint and integrating coupon application + delivery-zone-driven shipping display in the checkout UI.

Changes:

  • Added POST /api/store/[slug]/coupons/validate public endpoint to validate coupon codes and compute discounts.
  • Updated checkout page to apply/remove coupons, include discount amounts in totals/payload, and added a required delivery zone selector affecting shipping display/calculation.
  • Made shipping/billing State/Province optional and updated step numbering/UI accordingly.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.

File Description
src/app/store/[slug]/checkout/page.tsx Adds delivery zone selection and coupon application UI/state; updates totals and order payload to include discounts.
src/app/api/store/[slug]/coupons/validate/route.ts Introduces a public coupon validation endpoint for storefront checkout.

Comment on lines +88 to +98
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);
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else branch treats all non-PERCENTAGE discount types as a fixed-amount discount against orderAmount. This is incorrect for FREE_SHIPPING (and may diverge from discountService.applyCode, which also supports FIXED, FIXED_AMOUNT, and FREE_SHIPPING). Please handle the full DiscountType enum explicitly or delegate to discountService.applyCode to avoid incorrect discount calculations.

Suggested change
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);
switch (coupon.type) {
case '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);
}
break;
}
case 'FIXED':
case 'FIXED_AMOUNT': {
// FIXED / FIXED_AMOUNT – value is minor units (paisa)
// Cannot discount more than the order amount
discountAmount = Math.min(coupon.value, orderAmount);
break;
}
case 'FREE_SHIPPING': {
// FREE_SHIPPING does not reduce the order subtotal in this endpoint
// (shipping discount should be handled separately).
discountAmount = 0;
break;
}
default: {
// Unknown or unsupported discount type – do not apply a subtotal discount
discountAmount = 0;
break;
}

Copilot uses AI. Check for mistakes.
const total = subtotal + tax + shipping;
const discount = appliedCoupon?.discountAmount ?? 0;
const total = subtotal + tax + shipping - discount;

Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

discount is derived from appliedCoupon.discountAmount, which is a static number returned at the moment the coupon was applied. If cart items/subtotal change afterwards (e.g., quantity changes, cross-tab cart updates, invalid items removed), the displayed discount and total can become incorrect—especially for percentage-based coupons or minimum-order coupons. Consider storing the applied coupon's code + type/value and recalculating against the current subtotal (or re-validating via the API whenever subtotal changes) to keep totals consistent.

Suggested change
// Re-validate applied coupon whenever subtotal changes so discount stays in sync
useEffect(() => {
if (!appliedCoupon) {
return;
}
const controller = new AbortController();
const revalidateCoupon = async () => {
try {
const res = await fetch(storeApiUrl("/coupons/validate"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
code: appliedCoupon.code,
orderAmount: subtotal,
}),
signal: controller.signal,
});
const data = await res.json();
if (!res.ok) {
// Coupon is no longer valid for the updated cart total
setCouponError(data.error || "Coupon no longer valid for updated cart total");
setAppliedCoupon(null);
setValue("discountCode", undefined);
return;
}
setAppliedCoupon({
...appliedCoupon,
discountAmount: data.discountAmount,
description: data.description ?? appliedCoupon.description,
});
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return;
}
// Swallow other errors here; user still has the last known valid discount
}
};
void revalidateCoupon();
return () => {
controller.abort();
};
}, [appliedCoupon, subtotal, storeApiUrl, setAppliedCoupon, setValue, setCouponError]);

Copilot uses AI. Check for mistakes.
Comment on lines +686 to +690
{!deliveryLocation && (
<p className="text-sm text-amber-600 dark:text-amber-400 mt-3">
⚠ Please select a delivery zone to see the shipping charge.
</p>
)}
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is labeled as a required step for shipping calculation, but nothing prevents checkout submission when deliveryLocation is still unset. Because getCourierTotal() returns 0 when deliveryLocation is null, users can place orders while shippingAmount is effectively uncalculated. Please enforce a selected zone before allowing order creation (e.g., block onSubmit / disable submit and show a form-level error).

Copilot uses AI. Check for mistakes.
Comment on lines +26 to +32
const orderAmount: number = Number(body.orderAmount ?? 0);

if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}

if (orderAmount < 0) {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

orderAmount is parsed with Number(...), but non-numeric input becomes NaN and bypasses the orderAmount < 0 guard (NaN < 0 is false). That can lead to discountAmount becoming NaN and serializing to null. Validate Number.isFinite(orderAmount) (and ideally that it’s an integer minor-unit amount) before continuing.

Suggested change
const orderAmount: number = Number(body.orderAmount ?? 0);
if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}
if (orderAmount < 0) {
const rawOrderAmount = body.orderAmount;
const orderAmount: number =
typeof rawOrderAmount === 'number' ? rawOrderAmount : Number(rawOrderAmount);
if (!code) {
return NextResponse.json({ error: 'Coupon code is required' }, { status: 400 });
}
if (
!Number.isFinite(orderAmount) ||
!Number.isInteger(orderAmount) ||
orderAmount < 0
) {

Copilot uses AI. Check for mistakes.
Comment on lines +53 to +55
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint re-implements discount code validation via a raw Prisma query. It currently misses several checks already implemented in discountService.validateCode (e.g., startsAt, per-customer usage limits, targeted customer emails), which can make a coupon look valid here but get rejected during order creation (which uses discountService.applyCode). Consider delegating to discountService to keep behavior consistent.

Suggested change
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
AND: [
// Coupon has started (or has no start date)
{
OR: [
{ startsAt: null },
{ startsAt: { lte: new Date() } },
],
},
// Coupon has not expired (or has no expiry date)
{
OR: [
{ expiresAt: null },
{ expiresAt: { gt: new Date() } },
],
},

Copilot uses AI. Check for mistakes.
Comment on lines +19 to +21
*/
export async function POST(request: NextRequest, context: RouteContext) {
try {
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a public endpoint and currently has no rate limiting. Since coupon codes are guessable/brute-forceable and this hits the database, consider wrapping it with the existing withRateLimit middleware (as done for /api/store/[slug]/orders) to reduce abuse and operational load.

Suggested change
*/
export async function POST(request: NextRequest, context: RouteContext) {
try {
*/
// Basic in-memory rate limiting to reduce brute-force coupon abuse.
// Note: This is best-effort and may not be perfect across serverless instances,
// but it helps protect against obvious abuse and satisfies static analysis.
const COUPON_RATE_LIMIT_WINDOW_MS = 60_000; // 1 minute
const COUPON_RATE_LIMIT_MAX_REQUESTS = 20; // per client per window
type RateLimitState = {
windowStart: number;
count: number;
};
const couponRateLimitStore = new Map<string, RateLimitState>();
function getClientKey(request: NextRequest): string {
const forwardedFor = request.headers.get('x-forwarded-for');
const ip = forwardedFor?.split(',')[0]?.trim();
return ip || 'anonymous';
}
function consumeRateLimit(request: NextRequest): NextResponse | null {
const key = getClientKey(request);
const now = Date.now();
const current = couponRateLimitStore.get(key);
if (!current || now - current.windowStart > COUPON_RATE_LIMIT_WINDOW_MS) {
couponRateLimitStore.set(key, { windowStart: now, count: 1 });
return null;
}
if (current.count >= COUPON_RATE_LIMIT_MAX_REQUESTS) {
return NextResponse.json(
{ error: 'Too many coupon validation attempts. Please try again later.' },
{ status: 429 }
);
}
current.count += 1;
couponRateLimitStore.set(key, current);
return null;
}
export async function POST(request: NextRequest, context: RouteContext) {
try {
const limitedResponse = consumeRateLimit(request);
if (limitedResponse) {
return limitedResponse;
}
const limitedResponse = consumeRateLimit(request);
if (limitedResponse) {
return limitedResponse;
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants