From 373aaff8920cb9f60f27ce7ad210c8eff35db1ae Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Mon, 3 Nov 2025 09:47:24 +0100 Subject: [PATCH] Add stripe subscription card --- apps/main/package.json | 1 + .../src/components/ProtectedRoute.test.tsx | 1 + apps/main/src/components/SubscriptionCard.tsx | 263 ++++++++++++++++++ apps/main/src/hooks/stripe.ts | 256 +++++++++++++++++ apps/main/src/lib/routes.tsx | 2 +- apps/main/src/pages/reset-password.test.tsx | 8 +- apps/main/src/pages/settings.tsx | 4 + .../src/providers/UserStoreProvider.test.tsx | 1 + apps/main/src/utils/testHelpers.tsx | 1 + 9 files changed, 532 insertions(+), 5 deletions(-) create mode 100644 apps/main/src/components/SubscriptionCard.tsx create mode 100644 apps/main/src/hooks/stripe.ts diff --git a/apps/main/package.json b/apps/main/package.json index 15f139b..a862a2c 100644 --- a/apps/main/package.json +++ b/apps/main/package.json @@ -89,6 +89,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", "@react-stately/calendar": "^3.7.1", + "@stripe/stripe-js": "^8.2.0", "@supabase/supabase-js": "^2.49.3", "@tailwindcss/vite": "^4.1.5", "@tanstack/react-query": "^5.69.0", diff --git a/apps/main/src/components/ProtectedRoute.test.tsx b/apps/main/src/components/ProtectedRoute.test.tsx index f887421..d3e4a13 100644 --- a/apps/main/src/components/ProtectedRoute.test.tsx +++ b/apps/main/src/components/ProtectedRoute.test.tsx @@ -86,6 +86,7 @@ describe("ProtectedRoute", () => { last_name: "User", is_temporary: false, last_signed_in: null, + plan: "none" as const, }} > diff --git a/apps/main/src/components/SubscriptionCard.tsx b/apps/main/src/components/SubscriptionCard.tsx new file mode 100644 index 0000000..3a50b99 --- /dev/null +++ b/apps/main/src/components/SubscriptionCard.tsx @@ -0,0 +1,263 @@ +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 { Text } from "@xtablo/ui/components/typography"; +import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon } from "lucide-react"; +import { + useCancelSubscription, + useCreateCheckoutSession, + useCreatePortalSession, + useReactivateSubscription, + useSubscription, +} from "../hooks/stripe"; +import { useUser } from "../providers/UserStoreProvider"; + +/** + * 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 isPaying = user.plan !== "none"; + + console.log({ subscription }); + + // Replace with your actual price ID from Stripe Dashboard + const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || ""; + + const getStatusBadge = () => { + if (!subscription) { + return ( + + + Gratuit + + ); + } + + switch (subscription.status) { + case "active": + return ( + + + Actif + + ); + case "trialing": + return ( + + + Période d'essai + + ); + case "past_due": + return ( + + + Paiement en retard + + ); + default: + return ( + + {subscription.status} + + ); + } + }; + + return ( + + +
+
+ + Abonnement +
+ {getStatusBadge()} +
+ + {isPaying + ? "Gérez votre abonnement et votre facturation" + : "Passez à Standard pour débloquer toutes les fonctionnalités"} + +
+ + {subscriptionLoading ? ( +
+ +
+ ) : ( + <> + {/* Free Tier */} + {!isPaying && ( +
+
+
+

+ Plan Gratuit +

+

+ Fonctionnalités limitées +

+
+
+ + {!STANDARD_MONTHLY_PRICE_ID && ( + + Configuration Stripe requise + + )} +
+ )} + + {/* Standard Tier - Active */} + {isPaying && subscription && !subscription.cancel_at_period_end && ( +
+
+
+
+

+ Plan Standard +

+

+ Toutes les fonctionnalités débloquées +

+
+ {subscription.current_period_end && ( +
+

+ Renouvellement le{" "} + {new Date(subscription.current_period_end * 1000).toLocaleDateString( + "fr-FR", + { + day: "numeric", + month: "long", + year: "numeric", + } + )} +

+
+ )} +
+
+ +
+ + +
+
+ )} + + {/* Standard Tier - Canceling */} + {isPaying && subscription?.cancel_at_period_end && ( +
+
+
+
+ +
+

+ Abonnement en cours d'annulation +

+

+ Votre abonnement Standard sera annulé le{" "} + {subscription.current_period_end && + new Date(subscription.current_period_end * 1000).toLocaleDateString( + "fr-FR", + { + day: "numeric", + month: "long", + year: "numeric", + } + )} +

+

+ Vous aurez accès aux fonctionnalités Standard jusqu'à cette date. +

+
+
+
+
+ + +
+ )} + + )} +
+
+ ); +} diff --git a/apps/main/src/hooks/stripe.ts b/apps/main/src/hooks/stripe.ts new file mode 100644 index 0000000..5904f73 --- /dev/null +++ b/apps/main/src/hooks/stripe.ts @@ -0,0 +1,256 @@ +import { loadStripe } from "@stripe/stripe-js"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { + queryClient, + StripeApiPrice, + StripeApiProduct, + StripeApiSubscription, + toast, +} from "@xtablo/shared"; +import { supabase } from "../lib/supabase"; +import { useUser } from "../providers/UserStoreProvider"; +import { useAuthedApi } from "./auth"; + +// Initialize Stripe +const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""); + +/** + * Hook to get user's subscription status from Supabase + * Uses RPC function to access stripe data without modifying stripe schema + */ +export function useSubscription() { + const user = useUser(); + + return useQuery({ + queryKey: ["subscription", user.id], + queryFn: async () => { + // Get subscriptions via RPC function + const { data: subscriptions, error } = await supabase.rpc("get_user_stripe_subscriptions"); + + if (error) throw error; + + // Filter for active/trialing/past_due and get the most recent + const activeSubscription = subscriptions + ?.filter((s: StripeApiSubscription) => + ["active", "trialing", "past_due"].includes(s.status) + ) + .sort( + (a: StripeApiSubscription, b: StripeApiSubscription) => + b.current_period_end - a.current_period_end + )[0]; + + return activeSubscription || null; + }, + staleTime: 5 * 60 * 1000, // 5 minutes + }); +} + +/** + * Hook to check if user is paying (uses profile field for instant access) + */ +export function useIsPayingUser() { + const user = useUser(); + + // Direct access from user profile (fastest) + return { + data: user.plan !== "none", + isLoading: false, + }; +} + +/** + * Hook to get available prices for Standard plan from Supabase + * Uses RPC functions to access stripe data without modifying stripe schema + */ +export function useStripePrices() { + return useQuery({ + queryKey: ["stripe-prices"], + queryFn: async () => { + // Get products via RPC + const { data: products, error: productsError } = await supabase.rpc("get_stripe_products"); + + if (productsError) throw productsError; + + // Filter for Standard product + const standardProducts = + products?.filter((p: StripeApiProduct) => p.name === "Standard") || []; + + if (standardProducts.length === 0) return []; + + const productIds = standardProducts.map((p: StripeApiProduct) => p.id); + + // Get prices via RPC + const { data: allPrices, error: pricesError } = await supabase.rpc("get_stripe_prices"); + + if (pricesError) throw pricesError; + + // Filter for Standard product prices and sort + const prices = allPrices + ?.filter((price: StripeApiPrice) => productIds.includes(price.product)) + .sort((a: StripeApiPrice, b: StripeApiPrice) => a.unit_amount - b.unit_amount); + + // Attach product to each price + return ( + prices?.map((price: StripeApiPrice) => ({ + ...price, + product: standardProducts.find((p: StripeApiProduct) => p.id === price.product), + })) || [] + ); + }, + staleTime: 10 * 60 * 1000, // 10 minutes + }); +} + +/** + * Hook to create a Stripe checkout session + */ +export function useCreateCheckoutSession() { + const api = useAuthedApi(); + + const { mutate, isPending } = useMutation({ + mutationFn: async ({ + priceId, + successUrl, + cancelUrl, + }: { + priceId: string; + successUrl?: string; + cancelUrl?: string; + }) => { + const response = await api.post("/api/v1/stripe/create-checkout-session", { + priceId, + successUrl, + cancelUrl, + }); + + if (!response.data.url) { + throw new Error("No checkout URL returned"); + } + + // Redirect to Stripe Checkout + const stripe = await stripePromise; + if (!stripe) { + throw new Error("Stripe failed to load"); + } + + // Use the URL directly for redirect + window.location.href = response.data.url; + + return response.data; + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible de créer la session de paiement", + type: "error", + position: "top-center", + }); + }, + }); + + return { mutate, isPending }; +} + +/** + * Hook to create a Stripe customer portal session + */ +export function useCreatePortalSession() { + const api = useAuthedApi(); + + const { mutate, isPending } = useMutation({ + mutationFn: async (returnUrl?: string) => { + const response = await api.post("/api/v1/stripe/create-portal-session", { + returnUrl, + }); + + if (!response.data.url) { + throw new Error("No portal URL returned"); + } + + // Redirect to Stripe Customer Portal + window.location.href = response.data.url; + + return response.data; + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible d'ouvrir le portail client", + type: "error", + position: "top-center", + }); + }, + }); + + return { mutate, isPending }; +} + +/** + * Hook to cancel subscription at period end + */ +export function useCancelSubscription() { + const api = useAuthedApi(); + + const { mutate, isPending } = useMutation({ + mutationFn: async () => { + const response = await api.post("/api/v1/stripe/cancel-subscription"); + return response.data; + }, + onSuccess: () => { + toast.add({ + title: "Abonnement annulé", + description: "Votre abonnement sera annulé à la fin de la période en cours", + type: "success", + position: "top-center", + }); + queryClient.invalidateQueries({ queryKey: ["subscription"] }); + queryClient.invalidateQueries({ queryKey: ["is-paying"] }); + queryClient.invalidateQueries({ queryKey: ["user"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible d'annuler l'abonnement", + type: "error", + position: "top-center", + }); + }, + }); + + return { mutate, isPending }; +} + +/** + * Hook to reactivate a canceled subscription + */ +export function useReactivateSubscription() { + const api = useAuthedApi(); + + const { mutate, isPending } = useMutation({ + mutationFn: async () => { + const response = await api.post("/api/v1/stripe/reactivate-subscription"); + return response.data; + }, + onSuccess: () => { + toast.add({ + title: "Abonnement réactivé", + description: "Votre abonnement a été réactivé avec succès", + type: "success", + position: "top-center", + }); + queryClient.invalidateQueries({ queryKey: ["subscription"] }); + queryClient.invalidateQueries({ queryKey: ["is-paying"] }); + queryClient.invalidateQueries({ queryKey: ["user"] }); + }, + onError: (error: Error) => { + toast.add({ + title: "Erreur", + description: error.message || "Impossible de réactiver l'abonnement", + type: "error", + position: "top-center", + }); + }, + }); + + return { mutate, isPending }; +} diff --git a/apps/main/src/lib/routes.tsx b/apps/main/src/lib/routes.tsx index 211f18a..230ae6b 100644 --- a/apps/main/src/lib/routes.tsx +++ b/apps/main/src/lib/routes.tsx @@ -6,6 +6,7 @@ import { ProtectedRoute } from "../components/ProtectedRoute"; import { AvailabilitiesPage } from "../pages/availabilities"; import { ChantiersPage } from "../pages/chantiers"; import { ChatPage } from "../pages/chat"; +import { ConfirmEmailPage } from "../pages/confirm-email"; import { EventsPage } from "../pages/events"; import { FeedbackPage } from "../pages/feedback"; import { JoinPage } from "../pages/join"; @@ -22,7 +23,6 @@ import { SignUpPage } from "../pages/signup"; import { TabloPage } from "../pages/tablo"; import { TabloDetailsPage } from "../pages/tablo-details"; import { UpdatePasswordPage } from "../pages/update-password"; -import { ConfirmEmailPage } from "../pages/confirm-email"; import ChatProvider from "../providers/ChatProvider"; export const routes: RouteObject[] = [ diff --git a/apps/main/src/pages/reset-password.test.tsx b/apps/main/src/pages/reset-password.test.tsx index 08fe1fb..e06e96b 100644 --- a/apps/main/src/pages/reset-password.test.tsx +++ b/apps/main/src/pages/reset-password.test.tsx @@ -24,9 +24,7 @@ describe("ResetPasswordPage", () => { expect(screen.getByText(/resetPassword.title/i)).toBeInTheDocument(); expect(screen.getByLabelText(/resetPassword.emailLabel/i)).toBeInTheDocument(); - expect( - screen.getByRole("button", { name: /resetPassword.submit/i }) - ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /resetPassword.submit/i })).toBeInTheDocument(); }); it("displays help text", () => { @@ -89,7 +87,9 @@ describe("ResetPasswordPage", () => { fireEvent.click(submitButton); await waitFor(() => { - expect(screen.getByRole("button", { name: /resetPassword.backToLogin/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /resetPassword.backToLogin/i }) + ).toBeInTheDocument(); }); }); diff --git a/apps/main/src/pages/settings.tsx b/apps/main/src/pages/settings.tsx index dfa692e..c8a2a46 100644 --- a/apps/main/src/pages/settings.tsx +++ b/apps/main/src/pages/settings.tsx @@ -26,6 +26,7 @@ import { CameraIcon, CookieIcon, Loader2Icon, Trash2Icon, UploadIcon } from "luc import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { LanguageSelector } from "../components/LanguageSelector"; +import { SubscriptionCard } from "../components/SubscriptionCard"; import { useIntroduction } from "../hooks/intros"; import { useRemoveAvatar, useUpdateProfile, useUploadAvatar } from "../hooks/profile"; import { useCookieConsent } from "../hooks/useCookieConsent"; @@ -293,6 +294,9 @@ export default function SettingsPage() { + {/* Subscription Section */} + + {t("settings:introduction.title")} diff --git a/apps/main/src/providers/UserStoreProvider.test.tsx b/apps/main/src/providers/UserStoreProvider.test.tsx index 0c97e87..00d85dd 100644 --- a/apps/main/src/providers/UserStoreProvider.test.tsx +++ b/apps/main/src/providers/UserStoreProvider.test.tsx @@ -68,6 +68,7 @@ describe("TestUserStoreProvider", () => { last_name: null, short_user_id: "short-id", last_signed_in: null, + plan: "none" as const, }; it("renders children with user", () => { diff --git a/apps/main/src/utils/testHelpers.tsx b/apps/main/src/utils/testHelpers.tsx index ad1f1a4..6146cf5 100644 --- a/apps/main/src/utils/testHelpers.tsx +++ b/apps/main/src/utils/testHelpers.tsx @@ -18,6 +18,7 @@ const defaultUser = { streamToken: "test-stream-token", is_temporary: false, last_signed_in: null, + plan: "none" as const, }; export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {