fix(credits): wire coupon Apply button to real backend validation
All checks were successful
build-and-release / build (push) Successful in 1m41s
All checks were successful
build-and-release / build (push) Successful in 1m41s
The previous coupon UI's Apply button just flipped a local boolean on any non-empty text - no call to the backend, so an invalid/expired code still showed a green "will be applied at checkout" confirmation. The actual discount only surfaced (or failed generically) at order creation. Now Apply calls the new POST /api/ai-credits/coupons/validate endpoint, shows the real discount amount on success or the real error message on failure, and the Pay button reflects the validated discounted price. Editing the code after a successful apply clears the validated state so a stale discount can't be sent. create_order still re-validates server-side regardless (unchanged, already correct). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
77febdaeb4
commit
55c832b620
1 changed files with 92 additions and 18 deletions
|
|
@ -74,6 +74,10 @@ type CheckoutState = {
|
||||||
error: string;
|
error: string;
|
||||||
couponCode: string;
|
couponCode: string;
|
||||||
couponApplied: boolean;
|
couponApplied: boolean;
|
||||||
|
couponLoading: boolean;
|
||||||
|
couponError: string;
|
||||||
|
couponDiscountPaise: number;
|
||||||
|
couponFinalPriceInr: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
async function apiFetch(path: string, opts?: RequestInit) {
|
async function apiFetch(path: string, opts?: RequestInit) {
|
||||||
|
|
@ -120,6 +124,10 @@ export default function CreditsPage(props: Props) {
|
||||||
error: "",
|
error: "",
|
||||||
couponCode: "",
|
couponCode: "",
|
||||||
couponApplied: false,
|
couponApplied: false,
|
||||||
|
couponLoading: false,
|
||||||
|
couponError: "",
|
||||||
|
couponDiscountPaise: 0,
|
||||||
|
couponFinalPriceInr: 0,
|
||||||
});
|
});
|
||||||
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];
|
||||||
|
|
@ -302,22 +310,65 @@ 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: "", couponCode: "", couponApplied: false });
|
setCheckout({ package: pkg, aiCreditPackage: null, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false, couponLoading: false, couponError: "", couponDiscountPaise: 0, couponFinalPriceInr: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openAiCreditCheckout = (pkg: AiCreditPackage) => {
|
const openAiCreditCheckout = (pkg: AiCreditPackage) => {
|
||||||
setCheckout({ package: null, aiCreditPackage: pkg, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false });
|
setCheckout({ package: null, aiCreditPackage: pkg, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false, couponLoading: false, couponError: "", couponDiscountPaise: 0, couponFinalPriceInr: 0 });
|
||||||
};
|
};
|
||||||
|
|
||||||
const closeCheckout = () => {
|
const closeCheckout = () => {
|
||||||
setCheckout({ package: null, aiCreditPackage: null, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false });
|
setCheckout({ package: null, aiCreditPackage: null, orderId: null, step: "form", error: "", couponCode: "", couponApplied: false, couponLoading: false, couponError: "", couponDiscountPaise: 0, couponFinalPriceInr: 0 });
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyCoupon = async (aiPkg: AiCreditPackage) => {
|
||||||
|
const code = checkout().couponCode.trim();
|
||||||
|
if (!code) return;
|
||||||
|
setCheckout((c) => ({ ...c, couponLoading: true, couponError: "", couponApplied: false }));
|
||||||
|
try {
|
||||||
|
const res = await apiFetch("/api/ai-credits/coupons/validate", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ package_id: aiPkg.id, coupon_code: code }),
|
||||||
|
});
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (res.ok && data.valid) {
|
||||||
|
setCheckout((c) => ({
|
||||||
|
...c,
|
||||||
|
couponApplied: true,
|
||||||
|
couponError: "",
|
||||||
|
couponDiscountPaise: data.discount_paise ?? 0,
|
||||||
|
couponFinalPriceInr: data.final_price_inr ?? aiPkg.price_inr,
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
setCheckout((c) => ({
|
||||||
|
...c,
|
||||||
|
couponApplied: false,
|
||||||
|
couponError: data.message || "Invalid coupon code",
|
||||||
|
couponDiscountPaise: 0,
|
||||||
|
couponFinalPriceInr: 0,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setCheckout((c) => ({
|
||||||
|
...c,
|
||||||
|
couponApplied: false,
|
||||||
|
couponError: "Network error validating coupon",
|
||||||
|
couponDiscountPaise: 0,
|
||||||
|
couponFinalPriceInr: 0,
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
setCheckout((c) => ({ ...c, couponLoading: 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();
|
// Only forward the coupon once it's actually been validated by
|
||||||
|
// applyCoupon - typed-but-unapplied text must not silently discount
|
||||||
|
// the order. create_order re-validates it server-side regardless.
|
||||||
|
const coupon = checkout().couponApplied ? 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({
|
body: JSON.stringify({
|
||||||
|
|
@ -701,12 +752,26 @@ export default function CreditsPage(props: Props) {
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Enter coupon code"
|
placeholder="Enter coupon code"
|
||||||
value={checkout().couponCode}
|
value={checkout().couponCode}
|
||||||
onInput={(e) => setCheckout((c) => ({ ...c, couponCode: e.currentTarget.value.toUpperCase(), couponApplied: false }))}
|
onInput={(e) =>
|
||||||
|
setCheckout((c) => ({
|
||||||
|
...c,
|
||||||
|
couponCode: e.currentTarget.value.toUpperCase(),
|
||||||
|
couponApplied: false,
|
||||||
|
couponError: "",
|
||||||
|
couponDiscountPaise: 0,
|
||||||
|
couponFinalPriceInr: 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={checkout().couponLoading}
|
||||||
style={{
|
style={{
|
||||||
flex: "1",
|
flex: "1",
|
||||||
padding: "9px 12px",
|
padding: "9px 12px",
|
||||||
"border-radius": "8px",
|
"border-radius": "8px",
|
||||||
border: checkout().couponApplied ? "1.5px solid #16A34A" : "1.5px solid #D1D5DB",
|
border: checkout().couponApplied
|
||||||
|
? "1.5px solid #16A34A"
|
||||||
|
: checkout().couponError
|
||||||
|
? "1.5px solid #DC2626"
|
||||||
|
: "1.5px solid #D1D5DB",
|
||||||
"font-size": "13px",
|
"font-size": "13px",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
"letter-spacing": "0.05em",
|
"letter-spacing": "0.05em",
|
||||||
|
|
@ -714,12 +779,8 @@ export default function CreditsPage(props: Props) {
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => checkout().aiCreditPackage && applyCoupon(checkout().aiCreditPackage!)}
|
||||||
const code = checkout().couponCode.trim();
|
disabled={checkout().couponLoading || !checkout().couponCode.trim()}
|
||||||
if (code) {
|
|
||||||
setCheckout((c) => ({ ...c, couponApplied: true }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{
|
style={{
|
||||||
padding: "9px 14px",
|
padding: "9px 14px",
|
||||||
"border-radius": "8px",
|
"border-radius": "8px",
|
||||||
|
|
@ -727,17 +788,24 @@ export default function CreditsPage(props: Props) {
|
||||||
background: "#F9FAFB",
|
background: "#F9FAFB",
|
||||||
"font-size": "13px",
|
"font-size": "13px",
|
||||||
"font-weight": "600",
|
"font-weight": "600",
|
||||||
cursor: "pointer",
|
cursor: checkout().couponLoading ? "wait" : "pointer",
|
||||||
color: "#374151",
|
color: "#374151",
|
||||||
"white-space": "nowrap",
|
"white-space": "nowrap",
|
||||||
|
opacity: checkout().couponLoading || !checkout().couponCode.trim() ? 0.6 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Apply
|
{checkout().couponLoading ? "Checking…" : "Apply"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<Show when={checkout().couponApplied && checkout().couponCode.trim()}>
|
<Show when={checkout().couponApplied}>
|
||||||
<p style={{ margin: "0", "font-size": "12px", color: "#16A34A" }}>
|
<p style={{ margin: "0", "font-size": "12px", color: "#16A34A" }}>
|
||||||
✓ Coupon will be applied at checkout
|
✓ Coupon applied — you save{" "}
|
||||||
|
{formatCurrency(checkout().couponDiscountPaise / 100)}
|
||||||
|
</p>
|
||||||
|
</Show>
|
||||||
|
<Show when={checkout().couponError}>
|
||||||
|
<p style={{ margin: "0", "font-size": "12px", color: "#DC2626" }}>
|
||||||
|
{checkout().couponError}
|
||||||
</p>
|
</p>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -769,8 +837,14 @@ export default function CreditsPage(props: Props) {
|
||||||
"font-weight": "700",
|
"font-weight": "700",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Pay {formatCurrency(checkout().package ? checkout().package!.price : checkout().aiCreditPackage!.price_inr)}
|
Pay{" "}
|
||||||
{checkout().couponApplied && checkout().couponCode.trim() ? " (coupon applied)" : ""}
|
{formatCurrency(
|
||||||
|
checkout().package
|
||||||
|
? checkout().package!.price
|
||||||
|
: checkout().couponApplied
|
||||||
|
? checkout().couponFinalPriceInr
|
||||||
|
: checkout().aiCreditPackage!.price_inr
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<p
|
<p
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue