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:
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
import { useFeatureBenefits } from "@/lib/hooks/use-subscription";
|
import { useFeatureBenefits } from "@/lib/hooks/use-subscription";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Check, X } from "lucide-react";
|
|
||||||
|
|
||||||
export function FeatureComparison() {
|
export function FeatureComparison() {
|
||||||
const { data: features, isLoading } = useFeatureBenefits();
|
const { data: features, isLoading } = useFeatureBenefits();
|
||||||
@@ -34,8 +33,7 @@ export function FeatureComparison() {
|
|||||||
|
|
||||||
if (!features || features.length === 0) return null;
|
if (!features || features.length === 0) return null;
|
||||||
|
|
||||||
// Sort by sort_order
|
const sortedFeatures = [...features].sort((a, b) => a.display_order - b.display_order);
|
||||||
const sortedFeatures = [...features].sort((a, b) => a.sort_order - b.sort_order);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -48,36 +46,26 @@ export function FeatureComparison() {
|
|||||||
<div className="flex items-center justify-between border-b pb-3 mb-3">
|
<div className="flex items-center justify-between border-b pb-3 mb-3">
|
||||||
<span className="text-sm font-medium text-muted-foreground">Feature</span>
|
<span className="text-sm font-medium text-muted-foreground">Feature</span>
|
||||||
<div className="flex gap-8 text-sm font-medium text-muted-foreground">
|
<div className="flex gap-8 text-sm font-medium text-muted-foreground">
|
||||||
<span className="w-12 text-center">Free</span>
|
<span className="w-24 text-center">Free</span>
|
||||||
<span className="w-12 text-center">Premium</span>
|
<span className="w-24 text-center">Pro</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Feature rows */}
|
{/* Feature rows */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{sortedFeatures.map((feature) => {
|
{sortedFeatures.map((feature, index) => (
|
||||||
const isFreeFeature = feature.tier === "free";
|
<div key={feature.feature_name || index} className="flex items-center justify-between py-1">
|
||||||
return (
|
<p className="text-sm font-medium">{feature.feature_name}</p>
|
||||||
<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="flex gap-8 shrink-0">
|
||||||
<div className="w-12 flex justify-center">
|
<span className="w-24 text-center text-sm text-muted-foreground">
|
||||||
{isFreeFeature ? (
|
{feature.free_tier_text}
|
||||||
<Check className="size-4 text-green-500" />
|
</span>
|
||||||
) : (
|
<span className="w-24 text-center text-sm font-medium">
|
||||||
<X className="size-4 text-muted-foreground/40" />
|
{feature.pro_tier_text}
|
||||||
)}
|
</span>
|
||||||
</div>
|
|
||||||
<div className="w-12 flex justify-center">
|
|
||||||
<Check className="size-4 text-green-500" />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,39 +1,48 @@
|
|||||||
"use client";
|
"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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 {
|
interface LimitBarProps {
|
||||||
label: string;
|
label: string;
|
||||||
current?: number;
|
max: number | null;
|
||||||
max: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function LimitBar({ label, max }: LimitBarProps) {
|
function LimitBar({ label, max }: LimitBarProps) {
|
||||||
// The API returns limits but not current usage counts. We show the max
|
const isUnlimited = max === null || max === -1;
|
||||||
// allowed value for now. When usage data is available from the API, we can
|
|
||||||
// display a real progress bar.
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
<span className="text-muted-foreground">{label}</span>
|
<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>
|
||||||
<div className="h-2 w-full rounded-full bg-muted">
|
<div className="h-2 w-full rounded-full bg-muted">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-primary transition-all"
|
className="h-full rounded-full bg-primary transition-all"
|
||||||
style={{ width: max === -1 ? "100%" : "0%" }}
|
style={{ width: isUnlimited ? "100%" : "0%" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getOrigin() {
|
||||||
|
if (typeof window !== "undefined") return window.location.origin;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
export function SubscriptionStatus() {
|
export function SubscriptionStatus() {
|
||||||
const { data: status, isLoading } = useSubscriptionStatus();
|
const { data: status, isLoading } = useSubscriptionStatus();
|
||||||
|
const checkoutMutation = useCreateCheckoutSession();
|
||||||
|
const portalMutation = useCreatePortalSession();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -63,8 +72,47 @@ export function SubscriptionStatus() {
|
|||||||
|
|
||||||
if (!status) return null;
|
if (!status) return null;
|
||||||
|
|
||||||
const isFree = status.tier === "free";
|
const tier = status.tier ?? "free";
|
||||||
const isPremium = status.tier === "premium";
|
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 (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -75,17 +123,27 @@ export function SubscriptionStatus() {
|
|||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
{/* Tier badge and status */}
|
{/* Tier badge and status */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Badge variant={isPremium ? "default" : "secondary"} className="gap-1">
|
<Badge variant={isPro ? "default" : "secondary"} className="gap-1">
|
||||||
{isPremium ? <Crown className="size-3" /> : null}
|
{isPro ? <Crown className="size-3" /> : null}
|
||||||
{status.tier.charAt(0).toUpperCase() + status.tier.slice(1)}
|
{tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">
|
||||||
{status.is_active ? "Active" : "Inactive"}
|
{status.is_active ? "Active" : "Inactive"}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Expiry date for premium */}
|
{/* Expiry date for pro */}
|
||||||
{isPremium && status.expires_at && (
|
{isPro && status.expires_at && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Renews on{" "}
|
Renews on{" "}
|
||||||
<span className="font-medium text-foreground">
|
<span className="font-medium text-foreground">
|
||||||
@@ -99,44 +157,80 @@ export function SubscriptionStatus() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Limits */}
|
{/* Limits */}
|
||||||
|
{currentLimits && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-medium">Plan Limits</h3>
|
<h3 className="text-sm font-medium">Plan Limits</h3>
|
||||||
<LimitBar label="Residences" max={status.limits.max_residences} />
|
<LimitBar label="Residences" max={currentLimits.properties} />
|
||||||
<LimitBar label="Tasks per Residence" max={status.limits.max_tasks_per_residence} />
|
<LimitBar label="Tasks per Residence" max={currentLimits.tasks} />
|
||||||
<LimitBar label="Contractors" max={status.limits.max_contractors} />
|
<LimitBar label="Contractors" max={currentLimits.contractors} />
|
||||||
<LimitBar label="Documents" max={status.limits.max_documents} />
|
<LimitBar label="Documents" max={currentLimits.documents} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2">
|
{/* Manage subscription for Pro users */}
|
||||||
<div className="flex items-center gap-2 text-sm">
|
{isPro && source === "stripe" && (
|
||||||
<div
|
<Button
|
||||||
className={`size-2 rounded-full ${status.limits.can_share ? "bg-green-500" : "bg-muted-foreground"}`}
|
variant="outline"
|
||||||
/>
|
className="gap-2"
|
||||||
<span className="text-muted-foreground">Sharing</span>
|
onClick={handleManageSubscription}
|
||||||
<span className="font-medium">{status.limits.can_share ? "Enabled" : "Disabled"}</span>
|
disabled={portalMutation.isPending}
|
||||||
</div>
|
>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
{portalMutation.isPending ? (
|
||||||
<div
|
<Loader2 className="size-4 animate-spin" />
|
||||||
className={`size-2 rounded-full ${status.limits.can_export ? "bg-green-500" : "bg-muted-foreground"}`}
|
) : (
|
||||||
/>
|
<ExternalLink className="size-4" />
|
||||||
<span className="text-muted-foreground">Export</span>
|
)}
|
||||||
<span className="font-medium">{status.limits.can_export ? "Enabled" : "Disabled"}</span>
|
Manage Subscription
|
||||||
</div>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
|
{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 */}
|
{/* Upgrade CTA for free tier */}
|
||||||
{isFree && (
|
{isFree && (
|
||||||
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
<div className="rounded-lg border border-primary/20 bg-primary/5 p-4">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-start gap-3">
|
||||||
<Sparkles className="size-5 text-primary mt-0.5" />
|
<Sparkles className="size-5 text-primary mt-0.5" />
|
||||||
|
<div className="space-y-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm font-medium">Upgrade to Premium</p>
|
<p className="text-sm font-medium">Upgrade to Pro</p>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Unlock unlimited residences, tasks, and more features.
|
Unlock unlimited residences, tasks, and more features.
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground pt-1">
|
</div>
|
||||||
Available through the Casera iOS or Android app.
|
<div className="flex flex-wrap gap-2">
|
||||||
</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+70
-19
@@ -25,37 +25,46 @@ export interface SubscriptionResponse {
|
|||||||
|
|
||||||
export interface SubscriptionStatusResponse {
|
export interface SubscriptionStatusResponse {
|
||||||
tier: string;
|
tier: string;
|
||||||
status: string;
|
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
|
subscribed_at?: string;
|
||||||
expires_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 {
|
export interface TierLimitsResponse {
|
||||||
max_residences: number;
|
properties: number | null;
|
||||||
max_tasks_per_residence: number;
|
tasks: number | null;
|
||||||
max_contractors: number;
|
contractors: number | null;
|
||||||
max_documents: number;
|
documents: number | null;
|
||||||
can_share: boolean;
|
}
|
||||||
can_export: boolean;
|
|
||||||
|
export interface UsageResponse {
|
||||||
|
residences?: number;
|
||||||
|
tasks?: number;
|
||||||
|
contractors?: number;
|
||||||
|
documents?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpgradeTriggerResponse {
|
export interface UpgradeTriggerResponse {
|
||||||
id: number;
|
trigger_key: string;
|
||||||
key: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
message: string;
|
||||||
action_text: string;
|
promo_html: string;
|
||||||
icon: string;
|
button_text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FeatureBenefitResponse {
|
export interface FeatureBenefitResponse {
|
||||||
id: number;
|
feature_name: string;
|
||||||
title: string;
|
free_tier_text: string;
|
||||||
description: string;
|
pro_tier_text: string;
|
||||||
icon: string;
|
display_order: number;
|
||||||
tier: string;
|
|
||||||
sort_order: number;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PromotionResponse {
|
export interface PromotionResponse {
|
||||||
@@ -151,3 +160,45 @@ export function restoreSubscription(
|
|||||||
body: JSON.stringify(data),
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -325,26 +325,23 @@ export const demoProvider: DataProvider = {
|
|||||||
subscription: {
|
subscription: {
|
||||||
getStatus: async () => ({
|
getStatus: async () => ({
|
||||||
tier: 'free',
|
tier: 'free',
|
||||||
status: 'active',
|
|
||||||
is_active: true,
|
is_active: true,
|
||||||
|
auto_renew: false,
|
||||||
limits: {
|
limits: {
|
||||||
max_residences: 2,
|
free: { properties: 2, tasks: 25, contractors: 10, documents: 20 },
|
||||||
max_tasks_per_residence: 25,
|
pro: { properties: null, tasks: null, contractors: null, documents: null },
|
||||||
max_contractors: 10,
|
|
||||||
max_documents: 20,
|
|
||||||
can_share: false,
|
|
||||||
can_export: false,
|
|
||||||
},
|
},
|
||||||
|
limitations_enabled: true,
|
||||||
}),
|
}),
|
||||||
getFeatureBenefits: async () => [
|
getFeatureBenefits: async () => [
|
||||||
{ id: 1, title: 'Unlimited Residences', description: 'Track all your properties', icon: '🏠', tier: 'pro', sort_order: 1 },
|
{ feature_name: 'Unlimited Residences', free_tier_text: 'Up to 2', pro_tier_text: 'Unlimited', display_order: 1 },
|
||||||
{ id: 2, title: 'Unlimited Tasks', description: 'Never hit a task limit', icon: '✅', tier: 'pro', sort_order: 2 },
|
{ feature_name: 'Unlimited Tasks', free_tier_text: 'Up to 25', pro_tier_text: 'Unlimited', display_order: 2 },
|
||||||
{ id: 3, title: 'Sharing', description: 'Share residences with family', icon: '👥', tier: 'pro', sort_order: 3 },
|
{ feature_name: 'Sharing', free_tier_text: 'No', pro_tier_text: 'Yes', display_order: 3 },
|
||||||
{ id: 4, title: 'PDF Reports', description: 'Export task reports', icon: '📄', tier: 'pro', sort_order: 4 },
|
{ feature_name: 'PDF Reports', free_tier_text: 'No', pro_tier_text: 'Yes', display_order: 4 },
|
||||||
],
|
],
|
||||||
getUpgradeTriggers: async () => [
|
getUpgradeTriggers: async () => [
|
||||||
{ id: 1, key: 'residence_limit', title: 'Need more properties?', description: 'Upgrade to track unlimited residences.', action_text: 'Upgrade', icon: '🏠' },
|
{ trigger_key: 'residence_limit', title: 'Need more properties?', message: 'Upgrade to track unlimited residences.', promo_html: '', button_text: 'Upgrade' },
|
||||||
{ id: 2, key: 'sharing', title: 'Want to share?', description: 'Upgrade to share residences with family members.', action_text: 'Upgrade', icon: '👥' },
|
{ trigger_key: 'sharing', title: 'Want to share?', message: 'Upgrade to share residences with family members.', promo_html: '', button_text: 'Upgrade' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
"use client";
|
"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 { 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() {
|
export function useSubscriptionStatus() {
|
||||||
const { subscription } = useDataProvider();
|
const { subscription } = useDataProvider();
|
||||||
@@ -31,3 +39,29 @@ export function useUpgradeTriggers() {
|
|||||||
staleTime: Infinity,
|
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') });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -62,12 +62,18 @@ export interface TierLimitsClientResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionStatusResponse {
|
export interface SubscriptionStatusResponse {
|
||||||
|
tier: string;
|
||||||
|
is_active: boolean;
|
||||||
subscribed_at: string | null;
|
subscribed_at: string | null;
|
||||||
expires_at: string | null;
|
expires_at: string | null;
|
||||||
auto_renew: boolean;
|
auto_renew: boolean;
|
||||||
usage: UsageResponse | null;
|
usage: UsageResponse | null;
|
||||||
limits: Record<string, TierLimitsClientResponse>;
|
limits: Record<string, TierLimitsClientResponse>;
|
||||||
limitations_enabled: boolean;
|
limitations_enabled: boolean;
|
||||||
|
trial_start?: string | null;
|
||||||
|
trial_end?: string | null;
|
||||||
|
trial_active?: boolean;
|
||||||
|
subscription_source?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpgradeTriggerResponse {
|
export interface UpgradeTriggerResponse {
|
||||||
@@ -101,6 +107,28 @@ export interface PromotionResponse {
|
|||||||
end_date: string;
|
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)
|
// Purchase response wrappers (inline maps from handler)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user