Add stripe subscription card
This commit is contained in:
parent
7bb90becb9
commit
373aaff892
9 changed files with 532 additions and 5 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ describe("ProtectedRoute", () => {
|
|||
last_name: "User",
|
||||
is_temporary: false,
|
||||
last_signed_in: null,
|
||||
plan: "none" as const,
|
||||
}}
|
||||
>
|
||||
<SessionTestProvider>
|
||||
|
|
|
|||
263
apps/main/src/components/SubscriptionCard.tsx
Normal file
263
apps/main/src/components/SubscriptionCard.tsx
Normal file
|
|
@ -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 (
|
||||
<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>
|
||||
{isPaying
|
||||
? "Gérez votre abonnement et votre facturation"
|
||||
: "Passez à Standard 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>
|
||||
) : (
|
||||
<>
|
||||
{/* Free Tier */}
|
||||
{!isPaying && (
|
||||
<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">
|
||||
Plan Gratuit
|
||||
</p>
|
||||
<p className="text-xs text-purple-700 dark:text-purple-300">
|
||||
Fonctionnalités limitées
|
||||
</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 à Standard
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!STANDARD_MONTHLY_PRICE_ID && (
|
||||
<Text className="text-xs text-red-600 dark:text-red-400">
|
||||
Configuration Stripe requise
|
||||
</Text>
|
||||
)}
|
||||
</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 Standard
|
||||
</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 Standard 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 Standard 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>
|
||||
);
|
||||
}
|
||||
256
apps/main/src/hooks/stripe.ts
Normal file
256
apps/main/src/hooks/stripe.ts
Normal file
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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[] = [
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Subscription Section */}
|
||||
<SubscriptionCard />
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{t("settings:introduction.title")}</CardTitle>
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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 = "/" } = {}) => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue