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 { useFamily } from "@/app/FamilyProvider";
|
||||||
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
import { Button, Card, Input, Select, Badge } from "@/components/ui";
|
||||||
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
import { StorageMeter, MemberLimitBanner } from "@/components/StorageMeter";
|
||||||
|
import { UpgradeButton } from "@/components/UpgradeButton";
|
||||||
import { fmtDate } from "@/lib/date-ist";
|
import { fmtDate } from "@/lib/date-ist";
|
||||||
|
|
||||||
interface Member {
|
interface Member {
|
||||||
|
|
@ -45,6 +46,21 @@ export default function SettingsPage() {
|
||||||
const [pedEditing, setPedEditing] = useState(false);
|
const [pedEditing, setPedEditing] = useState(false);
|
||||||
const [pedSaved, setPedSaved] = useState(false);
|
const [pedSaved, setPedSaved] = useState(false);
|
||||||
const [pedError, setPedError] = useState("");
|
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)
|
// Check if can invite more members (client-side pre-check; server enforces)
|
||||||
const canInvite = tier === "pro" || memberCount < 2;
|
const canInvite = tier === "pro" || memberCount < 2;
|
||||||
|
|
@ -192,6 +208,45 @@ export default function SettingsPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-4 space-y-3">
|
<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 */}
|
{/* My Profile Page */}
|
||||||
<a href="/settings/profile"
|
<a href="/settings/profile"
|
||||||
className="flex items-center justify-between bg-white dark:bg-gray-800 rounded-2xl p-4 shadow-sm mb-3">
|
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>
|
||||||
</div>
|
</div>
|
||||||
{tier === "free" && (
|
{tier === "free" && (
|
||||||
<Button size="sm">Upgrade</Button>
|
<a href="#upgrade">
|
||||||
|
<Button size="sm">Upgrade</Button>
|
||||||
|
</a>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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