feat(credits): add coupon code input to AI credit checkout
All checks were successful
build-and-release / build (push) Successful in 1m41s

Adds an optional coupon code field (uppercase, with Apply button and
confirmation tick) to the checkout modal, visible only for AI credit
package purchases. The code is passed as coupon_code to POST
/api/ai-credits/order; the backend applies the discount and the
discounted amount flows through to PayU.

CheckoutState gains couponCode + couponApplied fields. All open/close
reset paths clear them. Retry from error preserves the entered code.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tracewebstudio Dev 2026-08-14 11:19:07 +02:00
parent 74f321099f
commit 77febdaeb4

View file

@ -72,6 +72,8 @@ type CheckoutState = {
orderId: string | null; orderId: string | null;
step: "form" | "processing" | "success" | "error"; step: "form" | "processing" | "success" | "error";
error: string; error: string;
couponCode: string;
couponApplied: boolean;
}; };
async function apiFetch(path: string, opts?: RequestInit) { async function apiFetch(path: string, opts?: RequestInit) {
@ -116,6 +118,8 @@ export default function CreditsPage(props: Props) {
orderId: null, orderId: null,
step: "form", step: "form",
error: "", error: "",
couponCode: "",
couponApplied: false,
}); });
const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey); const isProfessional = () => PROFESSIONAL_ROLE_SET.has(props.roleKey);
const prefix = () => ROLE_PREFIXES[props.roleKey]; const prefix = () => ROLE_PREFIXES[props.roleKey];
@ -298,24 +302,28 @@ export default function CreditsPage(props: Props) {
onMount(loadAllData); onMount(loadAllData);
const openCheckout = (pkg: Package) => { const openCheckout = (pkg: Package) => {
setCheckout({ package: pkg, aiCreditPackage: null, orderId: null, step: "form", error: "" }); setCheckout({ package: pkg, aiCreditPackage: null, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false });
}; };
const openAiCreditCheckout = (pkg: AiCreditPackage) => { const openAiCreditCheckout = (pkg: AiCreditPackage) => {
setCheckout({ package: null, aiCreditPackage: pkg, orderId: null, step: "form", error: "" }); setCheckout({ package: null, aiCreditPackage: pkg, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false });
}; };
const closeCheckout = () => { const closeCheckout = () => {
setCheckout({ package: null, aiCreditPackage: null, orderId: null, step: "form", error: "" }); setCheckout({ package: null, aiCreditPackage: null, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false });
}; };
const processAiCreditPayment = async (aiPkg: AiCreditPackage) => { const processAiCreditPayment = async (aiPkg: AiCreditPackage) => {
setCheckout((c) => ({ ...c, step: "processing", error: "" })); setCheckout((c) => ({ ...c, step: "processing", error: "" }));
try { try {
const coupon = checkout().couponCode.trim();
const orderRes = await apiFetch("/api/ai-credits/order", { const orderRes = await apiFetch("/api/ai-credits/order", {
method: "POST", method: "POST",
body: JSON.stringify({ package_id: aiPkg.id }), body: JSON.stringify({
package_id: aiPkg.id,
...(coupon ? { coupon_code: coupon } : {}),
}),
}); });
const orderData = await orderRes.json().catch(() => ({})); const orderData = await orderRes.json().catch(() => ({}));
@ -682,6 +690,59 @@ export default function CreditsPage(props: Props) {
</div> </div>
</Show> </Show>
{/* Coupon code — only for AI credit purchases */}
<Show when={checkout().aiCreditPackage}>
<div style={{ display: "flex", "flex-direction": "column", gap: "6px" }}>
<label style={{ "font-size": "13px", "font-weight": "600", color: "#374151" }}>
Coupon Code <span style={{ "font-weight": "400", color: "#9CA3AF" }}>(optional)</span>
</label>
<div style={{ display: "flex", gap: "8px" }}>
<input
type="text"
placeholder="Enter coupon code"
value={checkout().couponCode}
onInput={(e) => setCheckout((c) => ({ ...c, couponCode: e.currentTarget.value.toUpperCase(), couponApplied: false }))}
style={{
flex: "1",
padding: "9px 12px",
"border-radius": "8px",
border: checkout().couponApplied ? "1.5px solid #16A34A" : "1.5px solid #D1D5DB",
"font-size": "13px",
outline: "none",
"letter-spacing": "0.05em",
}}
/>
<button
type="button"
onClick={() => {
const code = checkout().couponCode.trim();
if (code) {
setCheckout((c) => ({ ...c, couponApplied: true }));
}
}}
style={{
padding: "9px 14px",
"border-radius": "8px",
border: "1.5px solid #D1D5DB",
background: "#F9FAFB",
"font-size": "13px",
"font-weight": "600",
cursor: "pointer",
color: "#374151",
"white-space": "nowrap",
}}
>
Apply
</button>
</div>
<Show when={checkout().couponApplied && checkout().couponCode.trim()}>
<p style={{ margin: "0", "font-size": "12px", color: "#16A34A" }}>
Coupon will be applied at checkout
</p>
</Show>
</div>
</Show>
{/* PayU Notice */} {/* PayU Notice */}
<div <div
style={{ style={{
@ -709,6 +770,7 @@ export default function CreditsPage(props: Props) {
}} }}
> >
Pay {formatCurrency(checkout().package ? checkout().package!.price : checkout().aiCreditPackage!.price_inr)} Pay {formatCurrency(checkout().package ? checkout().package!.price : checkout().aiCreditPackage!.price_inr)}
{checkout().couponApplied && checkout().couponCode.trim() ? " (coupon applied)" : ""}
</button> </button>
<p <p