Add beta plan + upsell modals + block past_due users

This commit is contained in:
Arthur Belleville 2025-11-24 23:09:14 +01:00
parent 7ec848e37e
commit d158a204af
No known key found for this signature in database
15 changed files with 502 additions and 43 deletions

View file

@ -3,6 +3,9 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext";
import { Toaster } from "@xtablo/ui/components/sonner";
import { BrowserRouter as Router, useRoutes } from "react-router-dom";
import { CookieBanner } from "./components/CookieBanner";
import { TrialUpsellModal } from "./components/TrialUpsellModal";
import { UpgradePanel } from "./components/UpgradePanel";
import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext";
import { useCookieConsent } from "./hooks/useCookieConsent";
import { useDatadogRumViewName } from "./hooks/useDatadogRumViewName";
import { publicRoutes } from "./lib/publicRoutes";
@ -21,9 +24,17 @@ const Routes = () => {
return publicElement;
}
// If app route matched, render it inside UserStoreProvider
// If app route matched, render it inside UserStoreProvider and UpgradeBlockProvider
if (appElement) {
return <UserStoreProvider>{appElement}</UserStoreProvider>;
return (
<UserStoreProvider>
<UpgradeBlockProvider>
<UpgradePanel />
<TrialUpsellModal />
{appElement}
</UpgradeBlockProvider>
</UserStoreProvider>
);
}
// Neither matched, show 404

View file

@ -13,10 +13,12 @@ import {
import { TypographyLarge, TypographyMuted } from "@xtablo/ui/components/typography";
import { cva, type VariantProps } from "class-variance-authority";
import {
AlertCircle,
CalendarCheckIcon,
CalendarIcon,
Circle,
ConstructionIcon,
CreditCard,
FileTextIcon,
Kanban,
LogOutIcon,
@ -26,6 +28,7 @@ import {
PlusIcon,
SendIcon,
SettingsIcon,
Sparkles,
SquareKanban,
} from "lucide-react";
import { useState } from "react";
@ -34,6 +37,7 @@ import { useTranslation } from "react-i18next";
import { Link as RouterLink, useLocation } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { useLogout } from "../hooks/auth";
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { isProd, isStaging } from "../lib/env";
import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider";
import { getXtabloIcon } from "../utils/iconHelpers";
@ -279,7 +283,16 @@ export const SideNavigation = ({ isMobileMenuOpen }: { isMobileMenuOpen: boolean
export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
const location = useLocation();
const isReadOnly = useIsReadOnlyUser();
const user = useUser();
const { t } = useTranslation("navigation");
const { daysRemaining } = useTrialExpiration();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
// Show upsell for users in trial period (not beta, not paid, and daysRemaining exists)
const shouldShowUpsell = daysRemaining !== null && user.plan === "none" && !user.is_temporary;
const isUrgent = daysRemaining !== null && daysRemaining <= 3;
type List<T> = T[];
@ -391,6 +404,77 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
})}
</ul>
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowUpsell && !isCollapsed && (
<li className="mb-2">
<div
className={twMerge(
"mx-2 mb-2 p-3 rounded-lg border",
isUrgent
? "bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
: "bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
)}
>
<div className="flex items-start gap-2 mb-2">
{isUrgent ? (
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0 mt-0.5" />
) : (
<Sparkles className="w-4 h-4 text-purple-600 dark:text-purple-400 shrink-0 mt-0.5" />
)}
<div className="flex-1 min-w-0">
<p
className={twMerge(
"text-xs font-medium",
isUrgent
? "text-red-900 dark:text-red-100"
: "text-purple-900 dark:text-purple-100"
)}
>
{daysRemaining === 0
? "Dernier jour d'essai"
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restant${daysRemaining > 1 ? "s" : ""}`}
</p>
<p
className={twMerge(
"text-xs mt-0.5",
isUrgent
? "text-red-700 dark:text-red-300"
: "text-purple-700 dark:text-purple-300"
)}
>
{isUrgent ? "Passez à Standard maintenant" : "Passez à Standard"}
</p>
</div>
</div>
<Button
size="sm"
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className={twMerge(
"w-full h-7 text-xs gap-1",
isUrgent
? "bg-gradient-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600"
: "bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
)}
>
{checkoutPending ? (
"..."
) : (
<>
<CreditCard className="w-3 h-3" />
Mettre à niveau
</>
)}
</Button>
</div>
</li>
)}
{/* <li>
<NavLink isActive={location.pathname === "/support"}>
<RouterLink

View file

@ -87,6 +87,7 @@ describe("ProtectedRoute", () => {
is_temporary: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
}}
>
<SessionTestProvider>

View file

@ -8,7 +8,7 @@ import {
CardTitle,
} from "@xtablo/ui/components/card";
import { Text } from "@xtablo/ui/components/typography";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon } from "lucide-react";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import {
useCancelSubscription,
useCreateCheckoutSession,
@ -31,14 +31,23 @@ export function SubscriptionCard() {
const { mutate: reactivateSubscription, isPending: reactivatePending } =
useReactivateSubscription();
const isPaying = user.plan !== "none";
console.log({ subscription });
const isPaying = user.plan !== "none" && user.plan !== "beta";
const isBeta = user.plan === "beta";
// 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 = () => {
// 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">
@ -90,9 +99,11 @@ export function SubscriptionCard() {
{getStatusBadge()}
</div>
<CardDescription>
{isPaying
? "Gérez votre abonnement et votre facturation"
: "Passez à Standard pour débloquer toutes les fonctionnalités"}
{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 à Standard pour débloquer toutes les fonctionnalités"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -102,8 +113,33 @@ export function SubscriptionCard() {
</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>
)}
{/* Free Tier */}
{!isPaying && (
{!isPaying && !isBeta && (
<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">

View file

@ -0,0 +1,134 @@
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react";
import { useEffect, useState } from "react";
import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe";
import { useMaybeUser } from "../providers/UserStoreProvider";
const MODAL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes
const LAST_SHOWN_KEY = "trial-upsell-modal-last-shown";
/**
* Auto-opening modal that shows every 15 minutes to remind users about trial expiration
* Only shows if daysRemaining is not null (user is in trial period)
*/
export function TrialUpsellModal() {
const [isOpen, setIsOpen] = useState(false);
const { daysRemaining } = useTrialExpiration();
const user = useMaybeUser();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
// Only show modal for users in trial period (not beta, not paid, and daysRemaining exists)
const shouldShowModal = daysRemaining !== null && user?.plan === "none" && !user?.is_temporary;
useEffect(() => {
if (!shouldShowModal) return;
const checkAndShowModal = () => {
const lastShown = localStorage.getItem(LAST_SHOWN_KEY);
const now = Date.now();
if (!lastShown || now - parseInt(lastShown) >= MODAL_INTERVAL_MS) {
setIsOpen(true);
localStorage.setItem(LAST_SHOWN_KEY, now.toString());
}
};
// Check immediately on mount
checkAndShowModal();
// Set up interval to check every 15 minutes
const interval = setInterval(checkAndShowModal, MODAL_INTERVAL_MS);
return () => clearInterval(interval);
}, [shouldShowModal]);
if (!shouldShowModal) {
return null;
}
const isUrgent = daysRemaining <= 3;
return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<div className="mx-auto w-12 h-12 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center mb-2">
{isUrgent ? (
<AlertCircle className="w-6 h-6 text-white" />
) : (
<Sparkles className="w-6 h-6 text-white" />
)}
</div>
<DialogTitle className="text-center">
{isUrgent
? `Plus que ${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} de période d'essai`
: `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restants dans votre période d'essai`}
</DialogTitle>
<DialogDescription className="text-center">
{isUrgent
? "Ne perdez pas l'accès à vos projets ! Passez au plan Standard pour continuer."
: "Profitez de toutes les fonctionnalités sans limite en passant au plan Standard."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<p className="text-sm font-medium">Avec Standard, vous bénéficiez de :</p>
<ul className="space-y-2">
{[
"Tablos et projets illimités",
"Planification avancée",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
<DialogFooter className="flex flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => setIsOpen(false)} className="sm:flex-1">
Plus tard
</Button>
<Button
onClick={() => {
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
});
setIsOpen(false);
}}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className="sm:flex-1 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...
</>
) : (
<>
<CreditCard className="w-4 h-4" />
Passer à Standard
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -0,0 +1,115 @@
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, Sparkles } from "lucide-react";
import { useUpgradeBlock } from "../contexts/UpgradeBlockContext";
import { useCreateCheckoutSession } from "../hooks/stripe";
/**
* Blocking upgrade panel that appears when users are past their trial period
* Prevents access to the app until they upgrade to a paid plan
*/
export function UpgradePanel() {
const { isBlocked } = useUpgradeBlock();
const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession();
const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
if (!isBlocked) {
return null;
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/95 backdrop-blur-sm">
<div className="w-full max-w-lg mx-4">
<Card className="shadow-2xl border-2">
<CardHeader className="text-center space-y-2">
<div className="mx-auto w-16 h-16 bg-gradient-to-br from-purple-500 to-blue-500 rounded-full flex items-center justify-center mb-2">
<Sparkles className="w-8 h-8 text-white" />
</div>
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
<CardDescription className="text-base">
Pour continuer à utiliser XTablo, passez au plan Standard et débloquez toutes les
fonctionnalités
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Features list */}
<div className="space-y-3">
<Text className="text-sm font-medium">Ce que vous obtenez avec Standard :</Text>
<ul className="space-y-2">
{[
"Tablos illimités",
"Planification avancée",
"Chat en temps réel",
"Stockage de fichiers",
"Support prioritaire",
].map((feature) => (
<li key={feature} className="flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-600 dark:text-green-400 shrink-0" />
<span>{feature}</span>
</li>
))}
</ul>
</div>
{/* Upgrade button */}
<div className="space-y-3">
<Button
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?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 h-12 text-base"
>
{checkoutPending ? (
<>
<Loader2Icon className="w-5 h-5 animate-spin" />
Chargement...
</>
) : (
<>
<CreditCard className="w-5 h-5" />
Passer à Standard
</>
)}
</Button>
{!STANDARD_MONTHLY_PRICE_ID && (
<div className="flex items-center gap-2 p-3 bg-red-50 dark:bg-red-950/20 border border-red-200 dark:border-red-800 rounded-lg">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 shrink-0" />
<Text className="text-xs text-red-600 dark:text-red-400">
Configuration Stripe requise. Veuillez contacter le support.
</Text>
</div>
)}
</div>
{/* Help text */}
<div className="text-center pt-2 border-t">
<Text className="text-xs text-muted-foreground">
Des questions ?{" "}
<a
href="mailto:support@xtablo.com"
className="text-primary hover:underline font-medium"
>
Contactez-nous
</a>
</Text>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View file

@ -0,0 +1,28 @@
import React, { createContext, useContext } from "react";
import { useIsPastTrial } from "../hooks/stripe";
interface UpgradeBlockContextValue {
isBlocked: boolean;
}
const UpgradeBlockContext = createContext<UpgradeBlockContextValue | null>(null);
export const useUpgradeBlock = () => {
const context = useContext(UpgradeBlockContext);
if (!context) {
throw new Error("useUpgradeBlock must be used within UpgradeBlockProvider");
}
return context;
};
interface UpgradeBlockProviderProps {
children: React.ReactNode;
}
export const UpgradeBlockProvider: React.FC<UpgradeBlockProviderProps> = ({ children }) => {
const { isPastTrial: isBlocked } = useIsPastTrial();
return (
<UpgradeBlockContext.Provider value={{ isBlocked }}>{children}</UpgradeBlockContext.Provider>
);
};

View file

@ -7,13 +7,16 @@ import {
StripeApiSubscription,
toast,
} from "@xtablo/shared";
import { useMemo } from "react";
import { supabase } from "../lib/supabase";
import { useUser } from "../providers/UserStoreProvider";
import { useMaybeUser, useUser } from "../providers/UserStoreProvider";
import { useAuthedApi } from "./auth";
// Initialize Stripe
const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || "");
const TRIAL_DURATION = 7 * 24 * 60 * 60 * 1000;
/**
* Hook to get user's subscription status from Supabase
* Uses RPC function to access stripe data without modifying stripe schema
@ -58,6 +61,53 @@ export function useIsPayingUser() {
};
}
export const useIsPastTrial = () => {
const user = useMaybeUser();
// Calculate if user is past trial inline
// User is past trial if: plan is 'none' AND created more than 7 days ago
const isPastTrial = useMemo(() => {
if (!user) return false;
const isPastTrial =
user.plan === "none" &&
new Date(user.created_at || "") < new Date(Date.now() - TRIAL_DURATION);
return isPastTrial;
}, [user]);
return { isPastTrial, isLoading: !user };
};
/**
* Hook to get when the user's trial expires
* Returns the trial expiration date (creation date + 7 days)
*/
export const useTrialExpiration = () => {
const user = useMaybeUser();
const trialExpiration = useMemo(() => {
if (!user?.created_at) return null;
const creationDate = new Date(user.created_at);
const expirationDate = new Date(creationDate.getTime() + TRIAL_DURATION);
return expirationDate;
}, [user?.created_at]);
const daysRemaining = useMemo(() => {
if (!trialExpiration) return null;
const now = new Date();
const diffInMs = trialExpiration.getTime() - now.getTime();
const diffInDays = Math.ceil(diffInMs / (1000 * 60 * 60 * 24));
return Math.max(0, diffInDays);
}, [trialExpiration]);
return { daysRemaining, isLoading: !user };
};
/**
* Hook to get available prices for Standard plan from Supabase
* Uses RPC functions to access stripe data without modifying stripe schema

View file

@ -69,6 +69,7 @@ describe("TestUserStoreProvider", () => {
short_user_id: "short-id",
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
};
it("renders children with user", () => {

View file

@ -21,6 +21,7 @@ const defaultUser = {
is_temporary: false,
last_signed_in: null,
plan: "none" as const,
created_at: new Date().toISOString(),
};
export const renderWithRouter = (ui: React.ReactNode, { route = "/" } = {}) => {

File diff suppressed because one or more lines are too long

View file

@ -1,30 +1,10 @@
export type Json = string | number | boolean | null | { [key: string]: Json | undefined } | Json[];
export type Database = {
graphql_public: {
Tables: {
[_ in never]: never;
};
Views: {
[_ in never]: never;
};
Functions: {
graphql: {
Args: {
extensions?: Json;
operationName?: string;
query?: string;
variables?: Json;
};
Returns: Json;
};
};
Enums: {
[_ in never]: never;
};
CompositeTypes: {
[_ in never]: never;
};
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: "13.0.4";
};
public: {
Tables: {
@ -401,6 +381,7 @@ export type Database = {
profiles: {
Row: {
avatar_url: string | null;
created_at: string | null;
email: string | null;
first_name: string | null;
id: string;
@ -413,6 +394,7 @@ export type Database = {
};
Insert: {
avatar_url?: string | null;
created_at?: string | null;
email?: string | null;
first_name?: string | null;
id: string;
@ -425,6 +407,7 @@ export type Database = {
};
Update: {
avatar_url?: string | null;
created_at?: string | null;
email?: string | null;
first_name?: string | null;
id?: string;
@ -913,7 +896,7 @@ export type Database = {
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
subscription_plan: "none" | "trial" | "standard";
subscription_plan: "none" | "trial" | "standard" | "beta";
task_status: "todo" | "in_progress" | "in_review" | "done";
};
CompositeTypes: {
@ -1041,13 +1024,10 @@ export type CompositeTypes<
: never;
export const Constants = {
graphql_public: {
Enums: {},
},
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"],
subscription_plan: ["none", "trial", "standard", "beta"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},

View file

@ -0,0 +1,7 @@
-- Add beta plan to subscription_plan enum
-- This plan can only be manually activated (not through Stripe flows)
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'beta';
-- Add comment to document that beta is manually activated only
COMMENT ON TYPE public.subscription_plan IS 'Subscription plans: none (no subscription), trial (trial period), standard (paid subscription), beta (manually activated beta access)';

View file

@ -0,0 +1,8 @@
-- Add created_at column to profiles table
-- This tracks when the user profile was created
-- The DEFAULT CURRENT_TIMESTAMP will automatically populate this for both existing and new profiles
ALTER TABLE public.profiles
ADD COLUMN created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP;
COMMENT ON COLUMN public.profiles.created_at IS 'Timestamp when the user profile was created';

View file

@ -387,6 +387,7 @@ export type Database = {
profiles: {
Row: {
avatar_url: string | null
created_at: string | null
email: string | null
first_name: string | null
id: string
@ -399,6 +400,7 @@ export type Database = {
}
Insert: {
avatar_url?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id: string
@ -411,6 +413,7 @@ export type Database = {
}
Update: {
avatar_url?: string | null
created_at?: string | null
email?: string | null
first_name?: string | null
id?: string
@ -899,7 +902,7 @@ export type Database = {
}
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
subscription_plan: "none" | "trial" | "standard"
subscription_plan: "none" | "trial" | "standard" | "beta"
task_status: "todo" | "in_progress" | "in_review" | "done"
}
CompositeTypes: {
@ -1032,7 +1035,7 @@ export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard"],
subscription_plan: ["none", "trial", "standard", "beta"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},