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:
Manohar Gupta 2026-06-06 12:23:12 +05:30
parent 99b5543eb9
commit 3604f7314d
2 changed files with 192 additions and 1 deletions

View file

@ -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" && (
<a href="#upgrade">
<Button size="sm">Upgrade</Button>
</a>
)}
</div>

View 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 itll 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>
);
}