diff --git a/src/app/subscription/cancel/page.tsx b/src/app/subscription/cancel/page.tsx new file mode 100644 index 0000000..2d82c8e --- /dev/null +++ b/src/app/subscription/cancel/page.tsx @@ -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 ( +
+
+ +
+

Checkout cancelled

+

+ Your subscription checkout was cancelled. No charges were made. You can + try again anytime from the settings page. +

+ +
+ ); +} diff --git a/src/app/subscription/success/page.tsx b/src/app/subscription/success/page.tsx new file mode 100644 index 0000000..51940b0 --- /dev/null +++ b/src/app/subscription/success/page.tsx @@ -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 ( +
+
+ +
+

Thank you!

+

+ Your Pro subscription is now active. Enjoy unlimited access to all + Casera features. +

+ +
+ ); +} diff --git a/src/components/settings/feature-comparison.tsx b/src/components/settings/feature-comparison.tsx index c9f98a4..bf11cee 100644 --- a/src/components/settings/feature-comparison.tsx +++ b/src/components/settings/feature-comparison.tsx @@ -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 ( @@ -48,36 +46,26 @@ export function FeatureComparison() {
Feature
- Free - Premium + Free + Pro
{/* Feature rows */}
- {sortedFeatures.map((feature) => { - const isFreeFeature = feature.tier === "free"; - return ( -
-
-

{feature.title}

-

{feature.description}

-
-
-
- {isFreeFeature ? ( - - ) : ( - - )} -
-
- -
-
+ {sortedFeatures.map((feature, index) => ( +
+

{feature.feature_name}

+
+ + {feature.free_tier_text} + + + {feature.pro_tier_text} +
- ); - })} +
+ ))}
diff --git a/src/components/settings/subscription-status.tsx b/src/components/settings/subscription-status.tsx index f2bfcf9..9e217bd 100644 --- a/src/components/settings/subscription-status.tsx +++ b/src/components/settings/subscription-status.tsx @@ -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 (
{label} - {max === -1 ? "Unlimited" : max} + {isUnlimited ? "Unlimited" : max}
); } +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 ( @@ -75,17 +123,27 @@ export function SubscriptionStatus() { {/* Tier badge and status */}
- - {isPremium ? : null} - {status.tier.charAt(0).toUpperCase() + status.tier.slice(1)} + + {isPro ? : null} + {tier.charAt(0).toUpperCase() + tier.slice(1)} {status.is_active ? "Active" : "Inactive"} + {status.trial_active && status.trial_end && ( + + Trial ends on{" "} + {new Date(status.trial_end).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + })} + + )}
- {/* Expiry date for premium */} - {isPremium && status.expires_at && ( + {/* Expiry date for pro */} + {isPro && status.expires_at && (

Renews on{" "} @@ -99,44 +157,80 @@ export function SubscriptionStatus() { )} {/* Limits */} -

-

Plan Limits

- - - - - -
-
-
- Sharing - {status.limits.can_share ? "Enabled" : "Disabled"} -
-
-
- Export - {status.limits.can_export ? "Enabled" : "Disabled"} -
+ {currentLimits && ( +
+

Plan Limits

+ + + +
-
+ )} + + {/* Manage subscription for Pro users */} + {isPro && source === "stripe" && ( + + )} + + {isPro && (source === "ios" || source === "apple") && ( +

+ Manage your subscription in the App Store on your iOS device. +

+ )} + + {isPro && source === "android" && ( +

+ Manage your subscription in Google Play on your Android device. +

+ )} {/* Upgrade CTA for free tier */} {isFree && (
-
-

Upgrade to Premium

-

- Unlock unlimited residences, tasks, and more features. -

-

- Available through the Casera iOS or Android app. -

+
+
+

Upgrade to Pro

+

+ Unlock unlimited residences, tasks, and more features. +

+
+
+ + +
diff --git a/src/lib/api/subscription.ts b/src/lib/api/subscription.ts index 6c8dc29..cff36a7 100644 --- a/src/lib/api/subscription.ts +++ b/src/lib/api/subscription.ts @@ -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; + 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 { + return apiFetch('/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 { + return apiFetch('/subscription/portal/', { + method: 'POST', + body: JSON.stringify(data), + }); +} diff --git a/src/lib/demo/demo-provider.ts b/src/lib/demo/demo-provider.ts index 7547d24..73cd9d7 100644 --- a/src/lib/demo/demo-provider.ts +++ b/src/lib/demo/demo-provider.ts @@ -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' }, ], }, diff --git a/src/lib/hooks/use-subscription.ts b/src/lib/hooks/use-subscription.ts index ddba546..2b6ba0d 100644 --- a/src/lib/hooks/use-subscription.ts +++ b/src/lib/hooks/use-subscription.ts @@ -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') }); + }, + }); +} diff --git a/src/lib/types/subscription.ts b/src/lib/types/subscription.ts index 9d62d3e..23bd6d1 100644 --- a/src/lib/types/subscription.ts +++ b/src/lib/types/subscription.ts @@ -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; 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) // ---------------------------------------------------------------------------