Skip to content

Commit f440b21

Browse files
committed
feat(web): upgrade button feedback + post-checkout messaging
The Upgrade buttons gave no click feedback and, after returning from a successful LemonSqueezy payment, the dashboard said nothing — the plan updates asynchronously when the webhook lands, so a user saw "nothing happened". - Per-button loading state: the clicked Upgrade/Downgrade shows a spinner + "Redirecting…" and disables while the checkout session is created. - Hover/active affordance on the upgrade buttons (brightness + press scale). - On return from checkout (redirect carries ?checkout=success) show a confirmation banner: "Payment received — your plan upgrades within a few minutes, no action needed." Param is cleaned from the URL after read.
1 parent d48d343 commit f440b21

2 files changed

Lines changed: 71 additions & 8 deletions

File tree

apps/web/src/app/dashboard/billing/page.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ export default function BillingPage() {
3636
const { stats } = useDashboardStats();
3737
const [isYearly, setIsYearly] = useState(false);
3838
const [isLoadingPortal, setIsLoadingPortal] = useState(false);
39+
// The tier whose checkout is currently being created (button shows a spinner).
40+
const [upgradingTier, setUpgradingTier] = useState<string | null>(null);
41+
// Set when LemonSqueezy redirects back after a successful checkout.
42+
const [checkoutSuccess, setCheckoutSuccess] = useState(false);
43+
useEffect(() => {
44+
if (new URLSearchParams(window.location.search).get("checkout") === "success") {
45+
setCheckoutSuccess(true);
46+
// Clean the param so a refresh doesn't keep showing the banner.
47+
window.history.replaceState(null, "", window.location.pathname);
48+
}
49+
}, []);
3950
// Live LemonSqueezy prices (source of truth) fetched via the catalog proxy.
4051
const [catalog, setCatalog] = useState<Catalog | null>(null);
4152
useEffect(() => {
@@ -82,16 +93,20 @@ export default function BillingPage() {
8293
window.open("mailto:sales@all-source.xyz?subject=Enterprise%20Plan%20Inquiry", "_blank");
8394
return;
8495
}
96+
setUpgradingTier(planTier);
8597
try {
8698
const response = await apiClient.createCheckout(planTier, billingPeriod, {
8799
tenantId: tenant?.id,
88100
email: user?.email,
89-
redirectUrl: `${window.location.origin}/dashboard/billing`,
101+
redirectUrl: `${window.location.origin}/dashboard/billing?checkout=success`,
90102
});
91103
if (response.data?.checkout_url) {
92104
window.location.href = response.data.checkout_url;
105+
return; // navigating away; keep the spinner until the redirect happens
93106
}
107+
setUpgradingTier(null);
94108
} catch (error) {
109+
setUpgradingTier(null);
95110
console.error("Failed to create checkout:", error);
96111
}
97112
};
@@ -107,6 +122,29 @@ export default function BillingPage() {
107122

108123
return (
109124
<div className="space-y-8">
125+
{/* Post-checkout confirmation — the plan updates asynchronously once the
126+
LemonSqueezy webhook lands, so set expectations explicitly. */}
127+
{checkoutSuccess && (
128+
<div className="flex items-start gap-3 rounded-lg border border-primary/30 bg-primary/10 p-4">
129+
<Check className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
130+
<div className="text-sm">
131+
<p className="font-semibold text-foreground">Payment received — thank you!</p>
132+
<p className="mt-1 text-muted-foreground">
133+
Your plan upgrades automatically within a few minutes once payment is confirmed. No
134+
action needed — refresh this page to see the change.
135+
</p>
136+
</div>
137+
<button
138+
type="button"
139+
onClick={() => setCheckoutSuccess(false)}
140+
className="ml-auto text-muted-foreground hover:text-foreground"
141+
aria-label="Dismiss"
142+
>
143+
<span aria-hidden>×</span>
144+
</button>
145+
</div>
146+
)}
147+
110148
{/* Header */}
111149
<BlurFade delay={0.1} inView>
112150
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
@@ -273,6 +311,7 @@ export default function BillingPage() {
273311
currentPlan={currentTier}
274312
isYearly={isYearly}
275313
catalog={catalog}
314+
loadingTier={upgradingTier}
276315
onUpgrade={handleUpgrade}
277316
/>
278317
</div>

apps/web/src/components/billing/plan-cards.tsx

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import { Button, Card, CardContent, CardHeader, CardTitle } from "@allsource/ui";
44
import { cn } from "@allsource/ui/utils";
5-
import { ArrowDown, Check, Sparkles } from "lucide-react";
5+
import { ArrowDown, Check, Loader2, Sparkles } from "lucide-react";
66
import { siteConfig } from "@/lib/config";
77
import { type Catalog, indexByTier } from "@/lib/pricing-catalog";
88

@@ -25,13 +25,16 @@ interface PlanCardsProps {
2525
currentPlan?: string;
2626
isYearly?: boolean;
2727
catalog?: Catalog | null;
28+
/** Tier whose checkout is being created — its button shows a spinner. */
29+
loadingTier?: string | null;
2830
onUpgrade?: (planName: string, billingPeriod: "monthly" | "annual") => void;
2931
}
3032

3133
export function PlanCards({
3234
currentPlan = "free",
3335
isYearly = false,
3436
catalog,
37+
loadingTier,
3538
onUpgrade,
3639
}: PlanCardsProps) {
3740
// Dashboard only offers checkout for tiers with a backend billing tier.
@@ -52,6 +55,7 @@ export function PlanCards({
5255
const isAbove = planRank > currentRank;
5356
const isBelow = planRank < currentRank;
5457
const isPopular = plan.isPopular;
58+
const isUpgrading = !!loadingTier && loadingTier === planBillingTier;
5559
const cat = prices[plan.tier];
5660
const displayPrice = isYearly
5761
? (cat?.annual?.per_month ?? plan.yearlyPrice)
@@ -129,21 +133,41 @@ export function PlanCards({
129133
</Button>
130134
) : isAbove ? (
131135
<Button
132-
className="w-full"
136+
className="w-full transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-70"
133137
variant={isPopular ? "default" : "outline"}
138+
disabled={isUpgrading}
134139
onClick={() => onUpgrade?.(planBillingTier, isYearly ? "annual" : "monthly")}
135140
>
136-
{isPopular && <Sparkles className="mr-2 h-4 w-4" />}
137-
Upgrade
141+
{isUpgrading ? (
142+
<>
143+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
144+
Redirecting…
145+
</>
146+
) : (
147+
<>
148+
{isPopular && <Sparkles className="mr-2 h-4 w-4" />}
149+
Upgrade
150+
</>
151+
)}
138152
</Button>
139153
) : isBelow && planBillingTier !== "free" ? (
140154
<Button
141-
className="w-full"
155+
className="w-full transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-70"
142156
variant="ghost"
157+
disabled={isUpgrading}
143158
onClick={() => onUpgrade?.(planBillingTier, isYearly ? "annual" : "monthly")}
144159
>
145-
<ArrowDown className="mr-2 h-4 w-4" />
146-
Downgrade
160+
{isUpgrading ? (
161+
<>
162+
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
163+
Redirecting…
164+
</>
165+
) : (
166+
<>
167+
<ArrowDown className="mr-2 h-4 w-4" />
168+
Downgrade
169+
</>
170+
)}
147171
</Button>
148172
) : (
149173
// Free plan when user is on a paid plan — no button, cancel via portal

0 commit comments

Comments
 (0)