feat(billing): Task 8 — checkout UI (UpgradeButton + settings Plan section)
- components/UpgradeButton.tsx: client component running the full flow —
lazy-loads checkout.js (warmed on mount), POST /create, opens Razorpay with
subscription_id, handler POSTs to /verify for UX, terracotta theme #C26B4E.
Shows "activating shortly" success state; never touches entitlement.
- settings page: dedicated #upgrade Plan section (anchor target for all the
existing /settings#upgrade CTAs from StorageMeter/MemberLimitBanner/etc):
free -> pitch + UpgradeButton
pro -> shows grants + Cancel subscription (cancel_at_cycle_end)
Replaced the old dead "Upgrade" button in Family section with #upgrade anchor.
Completes the 8-task build. Live acceptance gates (create returns sub_id,
webhook flips tier, full test-mode checkout) run after Task 0 (dashboard +
env vars) per the handoff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
99b5543eb9
commit
3604f7314d
2 changed files with 192 additions and 1 deletions
|
|
@ -7,6 +7,7 @@ import { useTheme } from "@/app/ThemeProvider";
|
|||
import { useFamily } from "@/app/FamilyProvider";
|
||||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||||
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||
import { fmtDate } from "@/lib/date-ist";
|
||||
|
||||
interface Member {
|
||||
|
|
@ -45,6 +46,21 @@ export default function SettingsPage() {
|
|||
const [pedEditing, setPedEditing] = useState(false);
|
||||
const [pedSaved, setPedSaved] = useState(false);
|
||||
const [pedError, setPedError] = useState("");
|
||||
const [cancelling, setCancelling] = useState(false);
|
||||
const [cancelMsg, setCancelMsg] = useState("");
|
||||
|
||||
const handleCancelSubscription = async () => {
|
||||
if (!window.confirm("Cancel your subscription? You'll keep premium until the end of your current billing period.")) return;
|
||||
setCancelling(true);
|
||||
try {
|
||||
const res = await fetch("/api/subscriptions/cancel", { method: "POST" });
|
||||
const data = await res.json();
|
||||
setCancelMsg(res.ok ? data.message : (data.error || "Could not cancel."));
|
||||
} catch {
|
||||
setCancelMsg("Could not reach the server. Try again.");
|
||||
}
|
||||
setCancelling(false);
|
||||
};
|
||||
|
||||
// Check if can invite more members (client-side pre-check; server enforces)
|
||||
const canInvite = tier === "pro" || memberCount < 2;
|
||||
|
|
@ -192,6 +208,45 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
|
||||
<div className="px-4 space-y-3">
|
||||
{/* Plan / Upgrade — anchor target for all "upgrade" CTAs across the app */}
|
||||
<div id="upgrade" className="bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm scroll-mt-20">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{tier === "pro" ? "✨ Tia Premium" : "Your Plan"}
|
||||
</p>
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${tier === "pro" ? "bg-green-100 text-green-700" : "bg-rose-100 text-rose-700"}`}>
|
||||
{tier === "pro" ? "Premium" : "Free"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{tier === "pro" ? (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
50 GB storage · up to 6 members · up to 3 baby profiles
|
||||
</p>
|
||||
{cancelMsg ? (
|
||||
<p className="text-sm text-amber-600 dark:text-amber-400">{cancelMsg}</p>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleCancelSubscription}
|
||||
disabled={cancelling}
|
||||
className="text-sm text-gray-400 hover:text-red-500 disabled:opacity-50 underline"
|
||||
>
|
||||
{cancelling ? "Cancelling…" : "Cancel subscription"}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">
|
||||
Upgrade for <strong>50 GB</strong> media storage, up to <strong>6 family members</strong>,
|
||||
and <strong>3 baby profiles</strong>. ₹199/month, cancel anytime.
|
||||
</p>
|
||||
<UpgradeButton />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* My Profile Page */}
|
||||
<a href="/settings/profile"
|
||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
|
||||
|
|
@ -244,7 +299,9 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
</div>
|
||||
{tier === "free" && (
|
||||
<Button size="sm">Upgrade</Button>
|
||||
<a href="#upgrade">
|
||||
<Button size="sm">Upgrade</Button>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
134
src/components/UpgradeButton.tsx
Normal file
134
src/components/UpgradeButton.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
// Minimal shape of the Razorpay Checkout global we use.
|
||||
interface RazorpayOptions {
|
||||
key: string;
|
||||
subscription_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
handler: (resp: RazorpayResponse) => void;
|
||||
prefill?: { email?: string; contact?: string };
|
||||
theme?: { color?: string };
|
||||
modal?: { ondismiss?: () => void };
|
||||
}
|
||||
interface RazorpayResponse {
|
||||
razorpay_payment_id: string;
|
||||
razorpay_subscription_id: string;
|
||||
razorpay_signature: string;
|
||||
}
|
||||
interface RazorpayInstance {
|
||||
open: () => void;
|
||||
on: (event: string, cb: (resp: unknown) => void) => void;
|
||||
}
|
||||
declare global {
|
||||
interface Window {
|
||||
Razorpay?: new (options: RazorpayOptions) => RazorpayInstance;
|
||||
}
|
||||
}
|
||||
|
||||
const CHECKOUT_SRC = "https://checkout.razorpay.com/v1/checkout.js";
|
||||
|
||||
function loadCheckout(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (window.Razorpay) return resolve(true);
|
||||
const existing = document.querySelector(`script[src="${CHECKOUT_SRC}"]`);
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve(true));
|
||||
existing.addEventListener("error", () => resolve(false));
|
||||
return;
|
||||
}
|
||||
const s = document.createElement("script");
|
||||
s.src = CHECKOUT_SRC;
|
||||
s.onload = () => resolve(true);
|
||||
s.onerror = () => resolve(false);
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
export function UpgradeButton({
|
||||
email,
|
||||
className,
|
||||
label = "Upgrade to Premium — ₹199/month",
|
||||
onActivating,
|
||||
}: {
|
||||
email?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
onActivating?: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
// Warm the checkout script so the first click is instant.
|
||||
useEffect(() => { loadCheckout(); }, []);
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const ok = await loadCheckout();
|
||||
if (!ok || !window.Razorpay) throw new Error("Could not load payment checkout. Check your connection.");
|
||||
|
||||
// 1. Create the subscription server-side.
|
||||
const createRes = await fetch("/api/subscriptions/create", { method: "POST" });
|
||||
const createData = await createRes.json();
|
||||
if (!createRes.ok) throw new Error(createData.error || "Could not start checkout.");
|
||||
|
||||
const { subscriptionId, keyId } = createData as { subscriptionId: string; keyId: string };
|
||||
|
||||
// 2. Open Razorpay Checkout for the mandate.
|
||||
const rzp = new window.Razorpay({
|
||||
key: keyId, // id only — never the secret
|
||||
subscription_id: subscriptionId,
|
||||
name: "Tia",
|
||||
description: "Tia Premium — ₹199/month",
|
||||
prefill: email ? { email } : undefined,
|
||||
theme: { color: "#C26B4E" }, // heirloom terracotta
|
||||
handler: async (resp) => {
|
||||
// 3. Verify for UX feedback only — entitlement comes via webhook.
|
||||
try {
|
||||
await fetch("/api/subscriptions/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(resp),
|
||||
});
|
||||
} catch { /* non-fatal — webhook still grants */ }
|
||||
setDone(true);
|
||||
onActivating?.();
|
||||
},
|
||||
modal: { ondismiss: () => setLoading(false) },
|
||||
});
|
||||
rzp.open();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Something went wrong.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (done) {
|
||||
return (
|
||||
<div className="rounded-xl bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/40 px-4 py-3 text-sm text-green-700 dark:text-green-300">
|
||||
🎉 Payment received! Your premium is activating — it’ll be live within a minute.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<button
|
||||
onClick={handleUpgrade}
|
||||
disabled={loading}
|
||||
className={
|
||||
className ??
|
||||
"w-full bg-rose-500 hover:bg-rose-600 disabled:opacity-60 text-white font-semibold px-5 py-3 rounded-xl text-sm transition-colors active:scale-[0.98]"
|
||||
}
|
||||
>
|
||||
{loading ? "Opening checkout…" : label}
|
||||
</button>
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue