Add Stripe checkout/portal integration and align subscription types with API

- Add Stripe Checkout session and Customer Portal API calls and mutation hooks
- Add success/cancel redirect pages for Stripe Checkout flow
- Update subscription status component with upgrade buttons, manage subscription, and trial badge
- Align subscription types (limits, feature benefits, upgrade triggers) with current API response shape
- Update demo provider data to match new type contracts
- Rename "Premium" tier references to "Pro" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-07 05:58:49 -06:00
parent 9ac3434b06
commit 4b8c10d768
8 changed files with 341 additions and 107 deletions
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { XCircle } from "lucide-react";
export default function SubscriptionCancelPage() {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
<div className="rounded-full bg-muted p-4 mb-4">
<XCircle className="size-10 text-muted-foreground" />
</div>
<h2 className="text-lg font-semibold">Checkout cancelled</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
Your subscription checkout was cancelled. No charges were made. You can
try again anytime from the settings page.
</p>
<Button asChild className="mt-6">
<Link href="/app/settings/subscription">Back to Settings</Link>
</Button>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { CheckCircle } from "lucide-react";
export default function SubscriptionSuccessPage() {
return (
<div className="flex flex-col items-center justify-center min-h-screen px-4">
<div className="rounded-full bg-green-100 p-4 mb-4 dark:bg-green-900/30">
<CheckCircle className="size-10 text-green-600 dark:text-green-400" />
</div>
<h2 className="text-lg font-semibold">Thank you!</h2>
<p className="mt-2 text-muted-foreground text-center max-w-md">
Your Pro subscription is now active. Enjoy unlimited access to all
Casera features.
</p>
<Button asChild className="mt-6">
<Link href="/app/settings/subscription">Back to Settings</Link>
</Button>
</div>
);
}
+15 -27
View File
@@ -3,7 +3,6 @@
import { useFeatureBenefits } from "@/lib/hooks/use-subscription";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Check, X } from "lucide-react";
export function FeatureComparison() {
const { data: features, isLoading } = useFeatureBenefits();
@@ -34,8 +33,7 @@ export function FeatureComparison() {
if (!features || features.length === 0) return null;
// Sort by sort_order
const sortedFeatures = [...features].sort((a, b) => a.sort_order - b.sort_order);
const sortedFeatures = [...features].sort((a, b) => a.display_order - b.display_order);
return (
<Card>
@@ -48,36 +46,26 @@ export function FeatureComparison() {
<div className="flex items-center justify-between border-b pb-3 mb-3">
<span className="text-sm font-medium text-muted-foreground">Feature</span>
<div className="flex gap-8 text-sm font-medium text-muted-foreground">
<span className="w-12 text-center">Free</span>
<span className="w-12 text-center">Premium</span>
<span className="w-24 text-center">Free</span>
<span className="w-24 text-center">Pro</span>
</div>
</div>
{/* Feature rows */}
<div className="space-y-3">
{sortedFeatures.map((feature) => {
const isFreeFeature = feature.tier === "free";
return (
<div key={feature.id} className="flex items-center justify-between py-1">
<div className="space-y-0.5">
<p className="text-sm font-medium">{feature.title}</p>
<p className="text-xs text-muted-foreground">{feature.description}</p>
</div>
<div className="flex gap-8 shrink-0">
<div className="w-12 flex justify-center">
{isFreeFeature ? (
<Check className="size-4 text-green-500" />
) : (
<X className="size-4 text-muted-foreground/40" />
)}
</div>
<div className="w-12 flex justify-center">
<Check className="size-4 text-green-500" />
</div>
</div>
{sortedFeatures.map((feature, index) => (
<div key={feature.feature_name || index} className="flex items-center justify-between py-1">
<p className="text-sm font-medium">{feature.feature_name}</p>
<div className="flex gap-8 shrink-0">
<span className="w-24 text-center text-sm text-muted-foreground">
{feature.free_tier_text}
</span>
<span className="w-24 text-center text-sm font-medium">
{feature.pro_tier_text}
</span>
</div>
);
})}
</div>
))}
</div>
</CardContent>
</Card>
+141 -47
View File
@@ -1,39 +1,48 @@
"use client";
import { useSubscriptionStatus } from "@/lib/hooks/use-subscription";
import { useSubscriptionStatus, useCreateCheckoutSession, useCreatePortalSession } from "@/lib/hooks/use-subscription";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Crown, Sparkles } from "lucide-react";
import { Crown, Sparkles, ExternalLink, Loader2 } from "lucide-react";
import { toast } from "sonner";
const STRIPE_PRICE_MONTHLY = process.env.NEXT_PUBLIC_STRIPE_PRICE_MONTHLY ?? "";
const STRIPE_PRICE_YEARLY = process.env.NEXT_PUBLIC_STRIPE_PRICE_YEARLY ?? "";
interface LimitBarProps {
label: string;
current?: number;
max: number;
max: number | null;
}
function LimitBar({ label, max }: LimitBarProps) {
// The API returns limits but not current usage counts. We show the max
// allowed value for now. When usage data is available from the API, we can
// display a real progress bar.
const isUnlimited = max === null || max === -1;
return (
<div className="space-y-1.5">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{label}</span>
<span className="font-medium">{max === -1 ? "Unlimited" : max}</span>
<span className="font-medium">{isUnlimited ? "Unlimited" : max}</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all"
style={{ width: max === -1 ? "100%" : "0%" }}
style={{ width: isUnlimited ? "100%" : "0%" }}
/>
</div>
</div>
);
}
function getOrigin() {
if (typeof window !== "undefined") return window.location.origin;
return "";
}
export function SubscriptionStatus() {
const { data: status, isLoading } = useSubscriptionStatus();
const checkoutMutation = useCreateCheckoutSession();
const portalMutation = useCreatePortalSession();
if (isLoading) {
return (
@@ -63,8 +72,47 @@ export function SubscriptionStatus() {
if (!status) return null;
const isFree = status.tier === "free";
const isPremium = status.tier === "premium";
const tier = status.tier ?? "free";
const isFree = tier === "free";
const isPro = tier === "pro" || tier === "premium";
const source = status.subscription_source;
// Get limits for the user's current tier (fall back to free limits)
const currentLimits = status.limits?.[tier] ?? status.limits?.["free"];
function handleCheckout(priceId: string) {
const origin = getOrigin();
checkoutMutation.mutate(
{
price_id: priceId,
success_url: `${origin}/subscription/success`,
cancel_url: `${origin}/subscription/cancel`,
},
{
onSuccess: (data) => {
window.location.href = data.checkout_url;
},
onError: (error) => {
toast.error(error.message || "Failed to start checkout");
},
},
);
}
function handleManageSubscription() {
const origin = getOrigin();
portalMutation.mutate(
{ return_url: `${origin}/app/settings/subscription` },
{
onSuccess: (data) => {
window.location.href = data.portal_url;
},
onError: (error) => {
toast.error(error.message || "Failed to open subscription portal");
},
},
);
}
return (
<Card>
@@ -75,17 +123,27 @@ export function SubscriptionStatus() {
<CardContent className="space-y-6">
{/* Tier badge and status */}
<div className="flex items-center gap-3">
<Badge variant={isPremium ? "default" : "secondary"} className="gap-1">
{isPremium ? <Crown className="size-3" /> : null}
{status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}
<Badge variant={isPro ? "default" : "secondary"} className="gap-1">
{isPro ? <Crown className="size-3" /> : null}
{tier.charAt(0).toUpperCase() + tier.slice(1)}
</Badge>
<span className="text-sm text-muted-foreground">
{status.is_active ? "Active" : "Inactive"}
</span>
{status.trial_active && status.trial_end && (
<Badge variant="outline" className="text-xs">
Trial ends on{" "}
{new Date(status.trial_end).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
})}
</Badge>
)}
</div>
{/* Expiry date for premium */}
{isPremium && status.expires_at && (
{/* Expiry date for pro */}
{isPro && status.expires_at && (
<p className="text-sm text-muted-foreground">
Renews on{" "}
<span className="font-medium text-foreground">
@@ -99,44 +157,80 @@ export function SubscriptionStatus() {
)}
{/* Limits */}
<div className="space-y-4">
<h3 className="text-sm font-medium">Plan Limits</h3>
<LimitBar label="Residences" max={status.limits.max_residences} />
<LimitBar label="Tasks per Residence" max={status.limits.max_tasks_per_residence} />
<LimitBar label="Contractors" max={status.limits.max_contractors} />
<LimitBar label="Documents" max={status.limits.max_documents} />
<div className="grid grid-cols-2 gap-4 pt-2">
<div className="flex items-center gap-2 text-sm">
<div
className={`size-2 rounded-full ${status.limits.can_share ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">Sharing</span>
<span className="font-medium">{status.limits.can_share ? "Enabled" : "Disabled"}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<div
className={`size-2 rounded-full ${status.limits.can_export ? "bg-green-500" : "bg-muted-foreground"}`}
/>
<span className="text-muted-foreground">Export</span>
<span className="font-medium">{status.limits.can_export ? "Enabled" : "Disabled"}</span>
</div>
{currentLimits && (
<div className="space-y-4">
<h3 className="text-sm font-medium">Plan Limits</h3>
<LimitBar label="Residences" max={currentLimits.properties} />
<LimitBar label="Tasks per Residence" max={currentLimits.tasks} />
<LimitBar label="Contractors" max={currentLimits.contractors} />
<LimitBar label="Documents" max={currentLimits.documents} />
</div>
</div>
)}
{/* Manage subscription for Pro users */}
{isPro && source === "stripe" && (
<Button
variant="outline"
className="gap-2"
onClick={handleManageSubscription}
disabled={portalMutation.isPending}
>
{portalMutation.isPending ? (
<Loader2 className="size-4 animate-spin" />
) : (
<ExternalLink className="size-4" />
)}
Manage Subscription
</Button>
)}
{isPro && (source === "ios" || source === "apple") && (
<p className="text-sm text-muted-foreground">
Manage your subscription in the App Store on your iOS device.
</p>
)}
{isPro && source === "android" && (
<p className="text-sm text-muted-foreground">
Manage your subscription in Google Play on your Android device.
</p>
)}
{/* Upgrade CTA for free tier */}
{isFree && (
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
<div className="flex items-start gap-3">
<Sparkles className="size-5 text-primary mt-0.5" />
<div className="space-y-1">
<p className="text-sm font-medium">Upgrade to Premium</p>
<p className="text-sm text-muted-foreground">
Unlock unlimited residences, tasks, and more features.
</p>
<p className="text-xs text-muted-foreground pt-1">
Available through the Casera iOS or Android app.
</p>
<div className="space-y-3">
<div className="space-y-1">
<p className="text-sm font-medium">Upgrade to Pro</p>
<p className="text-sm text-muted-foreground">
Unlock unlimited residences, tasks, and more features.
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
onClick={() => handleCheckout(STRIPE_PRICE_MONTHLY)}
disabled={checkoutMutation.isPending || !STRIPE_PRICE_MONTHLY}
>
{checkoutMutation.isPending ? (
<Loader2 className="size-4 animate-spin mr-1" />
) : null}
Monthly Pro
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleCheckout(STRIPE_PRICE_YEARLY)}
disabled={checkoutMutation.isPending || !STRIPE_PRICE_YEARLY}
>
{checkoutMutation.isPending ? (
<Loader2 className="size-4 animate-spin mr-1" />
) : null}
Annual Pro
</Button>
</div>
</div>
</div>
</div>
+70 -19
View File
@@ -25,37 +25,46 @@ export interface SubscriptionResponse {
export interface SubscriptionStatusResponse {
tier: string;
status: string;
is_active: boolean;
subscribed_at?: string;
expires_at?: string;
limits: TierLimitsResponse;
auto_renew: boolean;
trial_start?: string | null;
trial_end?: string | null;
trial_active?: boolean;
subscription_source?: string;
usage?: UsageResponse | null;
limits: Record<string, TierLimitsResponse>;
limitations_enabled: boolean;
}
export interface TierLimitsResponse {
max_residences: number;
max_tasks_per_residence: number;
max_contractors: number;
max_documents: number;
can_share: boolean;
can_export: boolean;
properties: number | null;
tasks: number | null;
contractors: number | null;
documents: number | null;
}
export interface UsageResponse {
residences?: number;
tasks?: number;
contractors?: number;
documents?: number;
}
export interface UpgradeTriggerResponse {
id: number;
key: string;
trigger_key: string;
title: string;
description: string;
action_text: string;
icon: string;
message: string;
promo_html: string;
button_text: string;
}
export interface FeatureBenefitResponse {
id: number;
title: string;
description: string;
icon: string;
tier: string;
sort_order: number;
feature_name: string;
free_tier_text: string;
pro_tier_text: string;
display_order: number;
}
export interface PromotionResponse {
@@ -151,3 +160,45 @@ export function restoreSubscription(
body: JSON.stringify(data),
});
}
// ---------------------------------------------------------------------------
// Stripe Checkout / Portal
// ---------------------------------------------------------------------------
export interface CheckoutSessionRequest {
price_id: string;
success_url: string;
cancel_url: string;
}
export interface CheckoutSessionResponse {
checkout_url: string;
}
export interface PortalSessionRequest {
return_url: string;
}
export interface PortalSessionResponse {
portal_url: string;
}
/** Create a Stripe Checkout session and return the checkout URL. */
export function createCheckoutSession(
data: CheckoutSessionRequest,
): Promise<CheckoutSessionResponse> {
return apiFetch<CheckoutSessionResponse>('/subscription/checkout/', {
method: 'POST',
body: JSON.stringify(data),
});
}
/** Create a Stripe Customer Portal session and return the portal URL. */
export function createPortalSession(
data: PortalSessionRequest,
): Promise<PortalSessionResponse> {
return apiFetch<PortalSessionResponse>('/subscription/portal/', {
method: 'POST',
body: JSON.stringify(data),
});
}
+10 -13
View File
@@ -325,26 +325,23 @@ export const demoProvider: DataProvider = {
subscription: {
getStatus: async () => ({
tier: 'free',
status: 'active',
is_active: true,
auto_renew: false,
limits: {
max_residences: 2,
max_tasks_per_residence: 25,
max_contractors: 10,
max_documents: 20,
can_share: false,
can_export: false,
free: { properties: 2, tasks: 25, contractors: 10, documents: 20 },
pro: { properties: null, tasks: null, contractors: null, documents: null },
},
limitations_enabled: true,
}),
getFeatureBenefits: async () => [
{ id: 1, title: 'Unlimited Residences', description: 'Track all your properties', icon: '🏠', tier: 'pro', sort_order: 1 },
{ id: 2, title: 'Unlimited Tasks', description: 'Never hit a task limit', icon: '✅', tier: 'pro', sort_order: 2 },
{ id: 3, title: 'Sharing', description: 'Share residences with family', icon: '👥', tier: 'pro', sort_order: 3 },
{ id: 4, title: 'PDF Reports', description: 'Export task reports', icon: '📄', tier: 'pro', sort_order: 4 },
{ feature_name: 'Unlimited Residences', free_tier_text: 'Up to 2', pro_tier_text: 'Unlimited', display_order: 1 },
{ feature_name: 'Unlimited Tasks', free_tier_text: 'Up to 25', pro_tier_text: 'Unlimited', display_order: 2 },
{ feature_name: 'Sharing', free_tier_text: 'No', pro_tier_text: 'Yes', display_order: 3 },
{ feature_name: 'PDF Reports', free_tier_text: 'No', pro_tier_text: 'Yes', display_order: 4 },
],
getUpgradeTriggers: async () => [
{ id: 1, key: 'residence_limit', title: 'Need more properties?', description: 'Upgrade to track unlimited residences.', action_text: 'Upgrade', icon: '🏠' },
{ id: 2, key: 'sharing', title: 'Want to share?', description: 'Upgrade to share residences with family members.', action_text: 'Upgrade', icon: '👥' },
{ trigger_key: 'residence_limit', title: 'Need more properties?', message: 'Upgrade to track unlimited residences.', promo_html: '', button_text: 'Upgrade' },
{ trigger_key: 'sharing', title: 'Want to share?', message: 'Upgrade to share residences with family members.', promo_html: '', button_text: 'Upgrade' },
],
},
+35 -1
View File
@@ -1,7 +1,15 @@
"use client";
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useDataProvider, useQueryKeyPrefix } from '@/lib/demo/data-provider-context';
import {
createCheckoutSession,
createPortalSession,
} from '@/lib/api/subscription';
import type {
CheckoutSessionRequest,
PortalSessionRequest,
} from '@/lib/api/subscription';
export function useSubscriptionStatus() {
const { subscription } = useDataProvider();
@@ -31,3 +39,29 @@ export function useUpgradeTriggers() {
staleTime: Infinity,
});
}
// ---------------------------------------------------------------------------
// Mutation hooks (Stripe Checkout / Portal)
// ---------------------------------------------------------------------------
export function useCreateCheckoutSession() {
const queryClient = useQueryClient();
const qk = useQueryKeyPrefix();
return useMutation({
mutationFn: (data: CheckoutSessionRequest) => createCheckoutSession(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qk('subscription') });
},
});
}
export function useCreatePortalSession() {
const queryClient = useQueryClient();
const qk = useQueryKeyPrefix();
return useMutation({
mutationFn: (data: PortalSessionRequest) => createPortalSession(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: qk('subscription') });
},
});
}
+28
View File
@@ -62,12 +62,18 @@ export interface TierLimitsClientResponse {
}
export interface SubscriptionStatusResponse {
tier: string;
is_active: boolean;
subscribed_at: string | null;
expires_at: string | null;
auto_renew: boolean;
usage: UsageResponse | null;
limits: Record<string, TierLimitsClientResponse>;
limitations_enabled: boolean;
trial_start?: string | null;
trial_end?: string | null;
trial_active?: boolean;
subscription_source?: string;
}
export interface UpgradeTriggerResponse {
@@ -101,6 +107,28 @@ export interface PromotionResponse {
end_date: string;
}
// ---------------------------------------------------------------------------
// Stripe Checkout / Portal
// ---------------------------------------------------------------------------
export interface CheckoutSessionRequest {
price_id: string;
success_url: string;
cancel_url: string;
}
export interface CheckoutSessionResponse {
checkout_url: string;
}
export interface PortalSessionRequest {
return_url: string;
}
export interface PortalSessionResponse {
portal_url: string;
}
// ---------------------------------------------------------------------------
// Purchase response wrappers (inline maps from handler)
// ---------------------------------------------------------------------------