xtablo-source/apps/main/src/components/SubscriptionCard.tsx
2026-03-08 19:29:44 +01:00

315 lines
13 KiB
TypeScript

import { Badge } from "@xtablo/ui/components/badge";
import { Button } from "@xtablo/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@xtablo/ui/components/card";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import { useOrganization } from "../hooks/organization";
import {
normalizeBillingPlan,
useCancelSubscription,
useCreateCheckoutSession,
useCreatePortalSession,
useReactivateSubscription,
useSubscription,
} from "../hooks/stripe";
import { useUser } from "../providers/UserStoreProvider";
/**
* Subscription management card for Settings page.
*/
export function SubscriptionCard() {
const user = useUser();
const { data: organizationData } = useOrganization();
const { data: subscription, isLoading: subscriptionLoading } = useSubscription();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const { mutate: openPortal, isPending: portalPending } = useCreatePortalSession();
const { mutate: cancelSubscription, isPending: cancelPending } = useCancelSubscription();
const { mutate: reactivateSubscription, isPending: reactivatePending } =
useReactivateSubscription();
const fallbackPlan = normalizeBillingPlan(user.plan);
const activePlan = organizationData?.active_subscription_plan ?? fallbackPlan;
const isBillingOwner = organizationData?.is_billing_owner ?? false;
const isPaying = activePlan === "team" || activePlan === "annual";
const isFounder = activePlan === "annual";
const requiredPlan = organizationData?.required_plan ?? "solo";
const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1;
const getStatusBadge = () => {
if (isFounder) {
return (
<Badge className="gap-1.5 bg-gradient-to-r from-purple-500 to-pink-500 hover:from-purple-600 hover:to-pink-600">
<Sparkles className="w-3 h-3" />
Founder
</Badge>
);
}
if (!subscription) {
return (
<Badge variant="secondary" className="gap-1.5">
<AlertCircle className="w-3 h-3" />
Sans abonnement
</Badge>
);
}
switch (subscription.status) {
case "active":
return (
<Badge className="gap-1.5 bg-green-500 hover:bg-green-600">
<CheckCircle2 className="w-3 h-3" />
Actif
</Badge>
);
case "trialing":
return (
<Badge className="gap-1.5 bg-blue-500 hover:bg-blue-600">
<CheckCircle2 className="w-3 h-3" />
Période d'essai
</Badge>
);
case "past_due":
return (
<Badge variant="destructive" className="gap-1.5">
<AlertCircle className="w-3 h-3" />
Paiement en retard
</Badge>
);
default:
return (
<Badge variant="secondary" className="gap-1.5">
{subscription.status}
</Badge>
);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CreditCard className="w-5 h-5" />
<CardTitle>Abonnement</CardTitle>
</div>
{getStatusBadge()}
</div>
<CardDescription>
{isFounder
? "Vous disposez du plan Founder (annuel) avec limites illimitées"
: isPaying
? "Gérez votre abonnement et votre facturation"
: "Choisissez Solo, Teams ou Founder"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{subscriptionLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2Icon className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : (
<>
{isFounder && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-purple-50 to-pink-50 dark:from-purple-950/20 dark:to-pink-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<div className="space-y-2">
<div className="flex items-center gap-2">
<Sparkles className="w-5 h-5 text-purple-600 dark:text-purple-400" />
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
Plan Founder (annuel)
</p>
</div>
<p className="text-xs text-purple-700 dark:text-purple-300">
Utilisateurs et tablos illimités.
</p>
</div>
</div>
</div>
)}
{!isFounder && (
<div className="space-y-3">
<div className="bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border border-purple-200 dark:border-purple-800 rounded-lg p-4">
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
Plan recommandé: {requiredPlan === "team" ? "Teams" : "Solo"}
</p>
<p className="text-xs text-purple-700 dark:text-purple-300 mt-1">
{requiredPlan === "team"
? `Votre organisation nécessite ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} Teams.`
: "Votre organisation peut continuer avec Solo (1 utilisateur, 10 tablos actifs)."}
</p>
</div>
{isBillingOwner ? (
<div className="grid grid-cols-1 gap-2">
<Button
onClick={() =>
createCheckout({
plan: requiredPlan === "team" ? "team" : "solo",
successUrl: `${window.location.origin}/settings?success=true`,
cancelUrl: `${window.location.origin}/settings?canceled=true`,
})
}
disabled={checkoutPending}
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
>
{checkoutPending ? (
<>
<Loader2Icon className="w-4 h-4 animate-spin" />
Chargement...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Passer au plan {requiredPlan === "team" ? "Teams" : "Solo"}
</>
)}
</Button>
<Button
variant="outline"
onClick={() =>
createCheckout({
plan: "founder",
successUrl: `${window.location.origin}/settings?success=true`,
cancelUrl: `${window.location.origin}/settings?canceled=true`,
})
}
disabled={checkoutPending}
>
Devenir Founder (99€/an)
</Button>
</div>
) : (
<div className="flex items-center gap-2 p-3 bg-amber-50 dark:bg-amber-950/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-amber-600 dark:text-amber-400 shrink-0" />
<p className="text-xs text-amber-700 dark:text-amber-300">
Seul le propriétaire de facturation de l'organisation peut modifier
l'abonnement.
</p>
</div>
)}
</div>
)}
{isPaying && subscription && !subscription.cancel_at_period_end && isBillingOwner && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-green-50 to-emerald-50 dark:from-green-950/20 dark:to-emerald-950/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-green-900 dark:text-green-100">
Plan {isFounder ? "Founder" : "Teams"}
</p>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Collaboration d'organisation activée
</p>
</div>
{subscription.current_period_end && (
<div className="pt-2 border-t border-green-200 dark:border-green-800">
<p className="text-xs text-green-600 dark:text-green-400">
Renouvellement le{" "}
{new Date(subscription.current_period_end * 1000).toLocaleDateString(
"fr-FR",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
</div>
)}
</div>
</div>
<div className="flex gap-2">
<Button
variant="outline"
onClick={() => openPortal(window.location.href)}
disabled={portalPending}
className="flex-1"
>
{portalPending ? (
<>
<Loader2Icon className="w-4 h-4 mr-2 animate-spin" />
Chargement...
</>
) : (
"Gérer l'abonnement"
)}
</Button>
<Button
variant="outline"
onClick={() => cancelSubscription()}
disabled={cancelPending}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-950"
>
{cancelPending ? <Loader2Icon className="w-4 h-4 animate-spin" /> : "Annuler"}
</Button>
</div>
</div>
)}
{isPaying && subscription?.cancel_at_period_end && isBillingOwner && (
<div className="space-y-4">
<div className="bg-gradient-to-br from-orange-50 to-red-50 dark:from-orange-950/20 dark:to-red-950/20 border border-orange-200 dark:border-orange-800 rounded-lg p-4">
<div className="space-y-3">
<div className="flex items-start gap-2">
<AlertCircle className="w-5 h-5 text-orange-600 dark:text-orange-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-orange-900 dark:text-orange-100">
Abonnement en cours d'annulation
</p>
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
Votre abonnement {isFounder ? "Founder" : "Teams"} sera annulé le{" "}
{subscription.current_period_end &&
new Date(subscription.current_period_end * 1000).toLocaleDateString(
"fr-FR",
{
day: "numeric",
month: "long",
year: "numeric",
}
)}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-2">
Vous gardez vos droits actuels jusqu'à cette date.
</p>
</div>
</div>
</div>
</div>
<Button
onClick={() => reactivateSubscription()}
disabled={reactivatePending}
className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
>
{reactivatePending ? (
<>
<Loader2Icon className="w-4 h-4 animate-spin" />
Chargement...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Réactiver l'abonnement
</>
)}
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
);
}