358 lines
14 KiB
TypeScript
358 lines
14 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 {
|
|
useCancelSubscription,
|
|
useCreateCheckoutSession,
|
|
useCreatePortalSession,
|
|
useReactivateSubscription,
|
|
useSubscription,
|
|
useTrialExpiration,
|
|
} from "../hooks/stripe";
|
|
import { useUser } from "../providers/UserStoreProvider";
|
|
import { pluralize } from "@xtablo/shared";
|
|
import { useMemo } from "react";
|
|
|
|
const allowedInfiniteUsers = [
|
|
"arbelleville@gmail.com",
|
|
"baptiste.belleville74@gmail.com",
|
|
"hugo@xtablo.com",
|
|
];
|
|
|
|
/**
|
|
* Subscription management card for Settings page
|
|
* Shows current subscription status and allows users to upgrade/manage
|
|
*/
|
|
export function SubscriptionCard() {
|
|
const user = useUser();
|
|
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 { daysRemaining } = useTrialExpiration();
|
|
|
|
const daysRemainingValue = useMemo(() => {
|
|
if (!daysRemaining) return 7;
|
|
return daysRemaining;
|
|
}, [daysRemaining]);
|
|
|
|
const isPaying = user.plan === "trial" || user.plan === "standard";
|
|
const isBeta = user.plan === "beta";
|
|
const isFreemium = user.plan === "free";
|
|
|
|
const showTrialBanner = user.plan === "none";
|
|
|
|
// Replace with your actual price ID from Stripe Dashboard
|
|
|
|
const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || "";
|
|
const standardPriceId = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
|
|
|
|
const priceId =
|
|
allowedInfiniteUsers.includes(user.email!) && infinitePriceId
|
|
? infinitePriceId
|
|
: standardPriceId;
|
|
|
|
const getStatusBadge = () => {
|
|
// Check for beta plan first
|
|
if (isBeta) {
|
|
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" />
|
|
Beta
|
|
</Badge>
|
|
);
|
|
}
|
|
|
|
if (!subscription) {
|
|
return (
|
|
<Badge variant="secondary" className="gap-1.5">
|
|
<AlertCircle className="w-3 h-3" />
|
|
Gratuit
|
|
</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>
|
|
{isBeta
|
|
? "Vous avez accès à toutes les fonctionnalités gratuitement en tant que bêta-testeur"
|
|
: isPaying
|
|
? "Gérez votre abonnement et votre facturation"
|
|
: "Passez à Starter pour débloquer toutes les fonctionnalités"}
|
|
</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>
|
|
) : (
|
|
<>
|
|
{/* Beta Plan */}
|
|
{isBeta && (
|
|
<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 Beta
|
|
</p>
|
|
</div>
|
|
<p className="text-xs text-purple-700 dark:text-purple-300">
|
|
Accès gratuit et illimité à toutes les fonctionnalités
|
|
</p>
|
|
<div className="pt-2 border-t border-purple-200 dark:border-purple-800">
|
|
<p className="text-xs text-purple-600 dark:text-purple-400">
|
|
Merci de faire partie de nos bêta-testeurs ! Votre retour nous aide à
|
|
améliorer XTablo.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{showTrialBanner && (
|
|
<div className="space-y-4">
|
|
<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">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-purple-900 dark:text-purple-100">
|
|
Accès gratuit pendant 7 jours
|
|
</p>
|
|
<p className="text-xs text-purple-700 dark:text-purple-300">
|
|
Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "}
|
|
pour passer au plan Starter.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={() =>
|
|
createCheckout({
|
|
priceId: priceId,
|
|
successUrl: `${window.location.origin}/settings?success=true`,
|
|
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
|
})
|
|
}
|
|
disabled={checkoutPending || !priceId}
|
|
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 Starter
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{isFreemium && (
|
|
<div className="space-y-4">
|
|
<div className="bg-gradient-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
|
<div className="space-y-2">
|
|
<p className="text-sm font-medium text-blue-900 dark:text-blue-100">
|
|
Plan Freemium
|
|
</p>
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de
|
|
toutes les fonctionnalités.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button
|
|
onClick={() =>
|
|
createCheckout({
|
|
priceId: STANDARD_MONTHLY_PRICE_ID,
|
|
successUrl: `${window.location.origin}/settings?success=true`,
|
|
cancelUrl: `${window.location.origin}/settings?canceled=true`,
|
|
})
|
|
}
|
|
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
|
|
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 Starter
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Standard Tier - Active */}
|
|
{isPaying && subscription && !subscription.cancel_at_period_end && (
|
|
<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 Starter
|
|
</p>
|
|
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
|
|
Toutes les fonctionnalités débloquées
|
|
</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>
|
|
)}
|
|
|
|
{/* Standard Tier - Canceling */}
|
|
{isPaying && subscription?.cancel_at_period_end && (
|
|
<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 Starter 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 aurez accès aux fonctionnalités Starter 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>
|
|
);
|
|
}
|