Onboarding + freemium

This commit is contained in:
Arthur Belleville 2025-12-01 19:34:30 +01:00
parent c72587e246
commit 06f2ac541b
No known key found for this signature in database
12 changed files with 610 additions and 14 deletions

View file

@ -1,15 +1,34 @@
import { Button } from "@xtablo/ui/components/button";
import { MenuIcon } from "lucide-react";
import { useState } from "react";
import { useEffect, useState } from "react";
import { Outlet } from "react-router-dom";
import { twMerge } from "tailwind-merge";
import { SideNavigation } from "./NavigationBar";
import { OnboardingModal } from "./OnboardingModal";
const ONBOARDING_STORAGE_KEY = "xtablo-onboarding-completed";
export function Layout() {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
// Check if user has completed onboarding
const hasCompletedOnboarding = localStorage.getItem(ONBOARDING_STORAGE_KEY);
if (!hasCompletedOnboarding) {
setShowOnboarding(false);
}
}, []);
const handleOnboardingComplete = () => {
localStorage.setItem(ONBOARDING_STORAGE_KEY, "true");
setShowOnboarding(false);
};
return (
<div className="flex h-screen">
<OnboardingModal open={showOnboarding} onComplete={handleOnboardingComplete} />
<Button
variant="ghost"
size="icon"

View file

@ -0,0 +1,227 @@
import { Button } from "@xtablo/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@xtablo/ui/components/dialog";
import { Calendar, CheckCircle, FolderKanban, MessageSquare, Sparkles } from "lucide-react";
import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
interface OnboardingModalProps {
open: boolean;
onComplete: () => void;
}
const STEP_ICONS = [Sparkles, FolderKanban, Calendar, MessageSquare, CheckCircle];
function StepContent({ stepKey }: { stepKey: string }) {
const { t } = useTranslation("onboarding");
if (stepKey === "welcome") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.welcome.content.intro")}</p>
<div className="bg-accent/50 rounded-lg p-4">
<p className="text-sm">{t("modal.steps.welcome.content.features")}</p>
</div>
</div>
);
}
if (stepKey === "tablos") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">
<Trans
i18nKey="modal.steps.tablos.content.intro"
ns="onboarding"
components={{ strong: <strong /> }}
/>
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "planning") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.planning.content.intro")}</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "chat") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.chat.content.intro")}</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "ready") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.ready.content.intro")}</p>
<div className="space-y-3">
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action1.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action1.description")}
</p>
</div>
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action2.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action2.description")}
</p>
</div>
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action3.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action3.description")}
</p>
</div>
</div>
</div>
);
}
return null;
}
export function OnboardingModal({ open, onComplete }: OnboardingModalProps) {
const { t } = useTranslation("onboarding");
const [currentStep, setCurrentStep] = useState(0);
const STEP_KEYS = ["welcome", "tablos", "planning", "chat", "ready"];
const stepData = useMemo(
() => ({
key: STEP_KEYS[currentStep],
title: t(`modal.steps.${STEP_KEYS[currentStep]}.title`),
description: t(`modal.steps.${STEP_KEYS[currentStep]}.description`),
icon: STEP_ICONS[currentStep],
}),
[currentStep, t]
);
const Icon = stepData.icon;
const isLastStep = currentStep === STEP_KEYS.length - 1;
const isFirstStep = currentStep === 0;
const handleNext = () => {
if (isLastStep) {
onComplete();
} else {
setCurrentStep((prev) => prev + 1);
}
};
const handleBack = () => {
setCurrentStep((prev) => Math.max(0, prev - 1));
};
const handleSkip = () => {
onComplete();
};
return (
<Dialog open={open} onOpenChange={(open) => !open && handleSkip()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl">{stepData.title}</DialogTitle>
<DialogDescription>{stepData.description}</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="py-6">
<StepContent stepKey={stepData.key} />
</div>
{/* Progress indicators */}
<div className="flex gap-1.5 justify-center mb-4">
{STEP_KEYS.map((_, index) => (
<div
key={index}
className={`h-1.5 rounded-full transition-all ${
index === currentStep
? "w-8 bg-primary"
: index < currentStep
? "w-1.5 bg-primary/50"
: "w-1.5 bg-muted"
}`}
/>
))}
</div>
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button variant="ghost" onClick={handleSkip} className="text-muted-foreground">
{t("modal.skipTutorial")}
</Button>
<div className="flex gap-2">
{!isFirstStep && (
<Button variant="outline" onClick={handleBack}>
{t("modal.back")}
</Button>
)}
<Button onClick={handleNext}>
{isLastStep ? t("modal.getStarted") : t("modal.next")}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -15,6 +15,7 @@ import {
useCreatePortalSession,
useReactivateSubscription,
useSubscription,
useTrialExpiration,
} from "../hooks/stripe";
import { useUser } from "../providers/UserStoreProvider";
@ -31,8 +32,13 @@ export function SubscriptionCard() {
const { mutate: reactivateSubscription, isPending: reactivatePending } =
useReactivateSubscription();
const isPaying = user.plan !== "none" && user.plan !== "beta";
const { daysRemaining } = useTrialExpiration();
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 STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || "";
@ -138,16 +144,15 @@ export function SubscriptionCard() {
</div>
)}
{/* Free Tier */}
{!isPaying && !isBeta && (
{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">
Plan Gratuit
Accès gratuit pendant 7 jours
</p>
<p className="text-xs text-purple-700 dark:text-purple-300">
Fonctionnalités limitées
Il vous reste {daysRemaining} jours pour passer à Standard.
</p>
</div>
</div>
@ -182,6 +187,21 @@ export function SubscriptionCard() {
</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">
Vous profitez d'un accès gratuit, un seul tablo ne peut être créé.
</p>
</div>
</div>
</div>
)}
{/* Standard Tier - Active */}
{isPaying && subscription && !subscription.cancel_at_period_end && (
<div className="space-y-4">

View file

@ -54,9 +54,11 @@ export function useSubscription() {
export function useIsPayingUser() {
const user = useUser();
const isPaying = user.plan === "trial" || user.plan === "standard";
// Direct access from user profile (fastest)
return {
data: user.plan !== "none",
data: isPaying,
isLoading: false,
};
}

View file

@ -8,6 +8,7 @@ import componentsEn from "./locales/en/components.json";
import modalsEn from "./locales/en/modals.json";
import navigationEn from "./locales/en/navigation.json";
import notesEn from "./locales/en/notes.json";
import onboardingEn from "./locales/en/onboarding.json";
import pagesEn from "./locales/en/pages.json";
import planningEn from "./locales/en/planning.json";
import settingsEn from "./locales/en/settings.json";
@ -20,6 +21,7 @@ import componentsFr from "./locales/fr/components.json";
import modalsFr from "./locales/fr/modals.json";
import navigationFr from "./locales/fr/navigation.json";
import notesFr from "./locales/fr/notes.json";
import onboardingFr from "./locales/fr/onboarding.json";
import pagesFr from "./locales/fr/pages.json";
import planningFr from "./locales/fr/planning.json";
import settingsFr from "./locales/fr/settings.json";
@ -42,6 +44,7 @@ i18n
components: componentsFr,
notes: notesFr,
tablo: tabloFr,
onboarding: onboardingFr,
},
en: {
common: commonEn,
@ -55,6 +58,7 @@ i18n
components: componentsEn,
notes: notesEn,
tablo: tabloEn,
onboarding: onboardingEn,
},
},
lng: "fr",

View file

@ -0,0 +1,67 @@
{
"modal": {
"skipTutorial": "Skip tutorial",
"back": "Back",
"next": "Next",
"getStarted": "Get Started",
"steps": {
"welcome": {
"title": "Welcome to Xtablo",
"description": "Your all-in-one platform for seamless client onboarding",
"content": {
"intro": "Xtablo helps you streamline your client relationships from the very first interaction to ongoing collaboration.",
"features": "Whether you're managing projects, scheduling meetings, or staying in touch with clients, Xtablo brings everything together in one beautiful interface."
}
},
"tablos": {
"title": "Organize with Tablos",
"description": "Create dedicated spaces for each client or project",
"content": {
"intro": "A <strong>Tablo</strong> is your workspace for managing a client relationship.",
"feature1": "Keep all client files, documents, and information in one place",
"feature2": "Invite team members and clients to collaborate",
"feature3": "Track progress and manage tasks effortlessly"
}
},
"planning": {
"title": "Schedule & Plan",
"description": "Manage your calendar and client availability",
"content": {
"intro": "Never miss a meeting or deadline with integrated planning tools.",
"feature1": "Create events and sync them across your team",
"feature2": "Set your availability and let clients book time with you",
"feature3": "Get notifications for upcoming events"
}
},
"chat": {
"title": "Communicate & Collaborate",
"description": "Built-in chat and notes for seamless teamwork",
"content": {
"intro": "Keep conversations organized and accessible to everyone who needs them.",
"feature1": "Real-time chat with your team and clients",
"feature2": "Share notes and important updates",
"feature3": "Keep a history of all project communications"
}
},
"ready": {
"title": "Ready to Get Started?",
"description": "Let's create your first Tablo and onboard your first client",
"content": {
"intro": "You're all set! Here are some quick actions to get you started:",
"action1": {
"title": "1. Create your first Tablo",
"description": "Click \"New Tablo\" to create a workspace for your first client"
},
"action2": {
"title": "2. Invite your team",
"description": "Add team members to collaborate on client projects"
},
"action3": {
"title": "3. Explore the features",
"description": "Check out Planning, Chat, and Notes to see what Xtablo can do"
}
}
}
}
}
}

View file

@ -0,0 +1,67 @@
{
"modal": {
"skipTutorial": "Passer le tutoriel",
"back": "Retour",
"next": "Suivant",
"getStarted": "Commencer",
"steps": {
"welcome": {
"title": "Bienvenue sur Xtablo",
"description": "Votre plateforme tout-en-un pour l'intégration fluide de vos clients",
"content": {
"intro": "Xtablo vous aide à rationaliser vos relations clients de la première interaction à la collaboration continue.",
"features": "Que vous gériez des projets, planifiez des réunions ou restiez en contact avec vos clients, Xtablo rassemble tout dans une interface élégante."
}
},
"tablos": {
"title": "Organisez avec les Tablos",
"description": "Créez des espaces dédiés pour chaque client ou projet",
"content": {
"intro": "Un <strong>Tablo</strong> est votre espace de travail pour gérer une relation client.",
"feature1": "Conservez tous les fichiers, documents et informations clients au même endroit",
"feature2": "Invitez des membres de l'équipe et des clients à collaborer",
"feature3": "Suivez l'avancement et gérez les tâches sans effort"
}
},
"planning": {
"title": "Planifiez & Organisez",
"description": "Gérez votre calendrier et les disponibilités de vos clients",
"content": {
"intro": "Ne manquez jamais une réunion ou une échéance grâce aux outils de planification intégrés.",
"feature1": "Créez des événements et synchronisez-les avec votre équipe",
"feature2": "Définissez vos disponibilités et laissez les clients réserver du temps avec vous",
"feature3": "Recevez des notifications pour les événements à venir"
}
},
"chat": {
"title": "Communiquez & Collaborez",
"description": "Chat et notes intégrés pour un travail d'équipe fluide",
"content": {
"intro": "Gardez les conversations organisées et accessibles à tous ceux qui en ont besoin.",
"feature1": "Chat en temps réel avec votre équipe et vos clients",
"feature2": "Partagez des notes et des mises à jour importantes",
"feature3": "Conservez un historique de toutes les communications du projet"
}
},
"ready": {
"title": "Prêt à commencer ?",
"description": "Créons votre premier Tablo et intégrons votre premier client",
"content": {
"intro": "Vous êtes prêt ! Voici quelques actions rapides pour commencer :",
"action1": {
"title": "1. Créez votre premier Tablo",
"description": "Cliquez sur \"Nouveau Tablo\" pour créer un espace de travail pour votre premier client"
},
"action2": {
"title": "2. Invitez votre équipe",
"description": "Ajoutez des membres de l'équipe pour collaborer sur les projets clients"
},
"action3": {
"title": "3. Explorez les fonctionnalités",
"description": "Découvrez la Planification, le Chat et les Notes pour voir ce que Xtablo peut faire"
}
}
}
}
}
}

File diff suppressed because one or more lines are too long

View file

@ -896,7 +896,7 @@ export type Database = {
};
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired";
subscription_plan: "none" | "trial" | "standard" | "beta";
subscription_plan: "none" | "trial" | "standard" | "beta" | "free";
task_status: "todo" | "in_progress" | "in_review" | "done";
};
CompositeTypes: {
@ -1027,7 +1027,7 @@ export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard", "beta"],
subscription_plan: ["none", "trial", "standard", "beta", "free"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},

View file

@ -0,0 +1,96 @@
-- Add "free" plan option and freemium helper utilities
-- Add the new enum value if it does not already exist
ALTER TYPE public.subscription_plan ADD VALUE IF NOT EXISTS 'free';
-- Helper to determine if freemium seats are still available
CREATE OR REPLACE FUNCTION public.is_freemium_available() RETURNS boolean
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
freemium_user_count integer;
BEGIN
SELECT COUNT(*) INTO freemium_user_count
FROM public.profiles
WHERE plan = 'free';
IF freemium_user_count >= 100 THEN
RETURN FALSE;
END IF;
IF DATE_PART('year', NOW()) >= 2026 THEN
RETURN FALSE;
END IF;
RETURN TRUE;
END;
$$;
COMMENT ON FUNCTION public.is_freemium_available() IS
'Returns true when less than 100 users currently have the free plan and the current year is before 2026';
ALTER FUNCTION public.is_freemium_available() OWNER TO postgres;
-- Update the new-user trigger to auto-assign the free plan when slots remain
CREATE OR REPLACE FUNCTION public.handle_new_user() RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
name TEXT;
first_name TEXT;
last_name TEXT;
is_temp BOOLEAN;
email_prefix TEXT;
assigned_plan public.subscription_plan := 'none';
BEGIN
-- Extract first_name and last_name from metadata
first_name = NEW.raw_user_meta_data ->> 'first_name';
last_name = NEW.raw_user_meta_data ->> 'last_name';
-- If first_name is not provided, extract it from email (part before @)
IF first_name IS NULL OR first_name = '' THEN
email_prefix = SPLIT_PART(NEW.email, '@', 1);
first_name = email_prefix;
END IF;
-- Determine the full name
IF NEW.raw_user_meta_data ->> 'name' IS NOT NULL
THEN
name = NEW.raw_user_meta_data ->> 'name';
-- If name is provided but not first/last, try to split it
IF first_name IS NULL AND last_name IS NULL AND name IS NOT NULL THEN
first_name = SPLIT_PART(name, ' ', 1);
IF ARRAY_LENGTH(STRING_TO_ARRAY(name, ' '), 1) > 1 THEN
last_name = SUBSTRING(name FROM LENGTH(SPLIT_PART(name, ' ', 1)) + 2);
END IF;
END IF;
ELSE
name = CONCAT(first_name, ' ', last_name);
END IF;
-- Check if the role is 'invited_user' in app_metadata
IF COALESCE(NEW.raw_user_meta_data->>'role', '') = 'invited_user'
THEN
is_temp = TRUE;
ELSE
is_temp = FALSE;
END IF;
-- Assign free plan when freemium slots are available and user is not temporary
IF NOT is_temp AND public.is_freemium_available() THEN
assigned_plan := 'free';
END IF;
INSERT INTO public.profiles (id, name, email, avatar_url, first_name, last_name, is_temporary, plan)
VALUES (NEW.id, name, NEW.email, NEW.raw_user_meta_data ->> 'avatar_url', first_name, last_name, is_temp, assigned_plan);
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION public.handle_new_user() IS
'Trigger function that creates a profile when a new user is created. Sets is_temporary=true for users with app_metadata.role=invited_user, extracts first name from email when missing, and assigns the free plan while freemium is available.';
ALTER FUNCTION public.handle_new_user() OWNER TO postgres;

View file

@ -1,5 +1,5 @@
begin;
select plan(31); -- Total number of tests (added 11 for handle_new_user)
select plan(35); -- Total number of tests (added freemium coverage)
-- ============================================================================
-- Trigger Function Existence Tests
@ -463,12 +463,106 @@ SELECT is(
'is_temporary should be false for regular users'
);
-- Test 5: Verify short_user_id is set (by another trigger)
-- Test 5: Regular users receive the freemium plan when available
DO $$
DECLARE
freemium_user_id uuid := gen_random_uuid();
freemium_email text := 'freemium_user_' || freemium_user_id::text || '@test.com';
BEGIN
INSERT INTO auth.users (
id, instance_id, aud, role, email,
encrypted_password, email_confirmed_at,
raw_user_meta_data, created_at, updated_at
)
VALUES (
freemium_user_id,
'00000000-0000-0000-0000-000000000000',
'authenticated',
'authenticated',
freemium_email,
'encrypted',
now(),
'{"first_name": "Free", "last_name": "User"}'::jsonb,
now(),
now()
);
PERFORM set_config('test.freemium_user_id', freemium_user_id::text, true);
END $$;
SELECT is(
(
SELECT plan
FROM public.profiles
WHERE id = current_setting('test.freemium_user_id')::uuid
LIMIT 1
),
'free'::public.subscription_plan,
'Non-temporary users should receive the free plan while freemium slots remain'
);
-- Test 6: Invited users do not receive the freemium plan
SELECT is(
(
SELECT plan
FROM public.profiles
WHERE id = current_setting('test.invited_user_id')::uuid
LIMIT 1
),
'none'::public.subscription_plan,
'Temporary invited users should remain on the none plan even if freemium is available'
);
-- Test 7: Verify short_user_id is set (by another trigger)
SELECT ok(
(SELECT short_user_id FROM public.profiles WHERE id = current_setting('test.new_user_id')::uuid LIMIT 1) IS NOT NULL,
'short_user_id should be set for new profile'
);
-- Test 8: is_freemium_available is true before quota is reached
SELECT is(
public.is_freemium_available(),
true,
'Freemium availability should be true when fewer than 100 free users exist'
);
-- Test 9: Freemium availability becomes false once 100 free users exist
DO $$
DECLARE
i integer;
quota_user_id uuid;
quota_email text;
BEGIN
FOR i IN 1..100 LOOP
quota_user_id := gen_random_uuid();
quota_email := format('freemium_quota_user_%s@test.com', i);
INSERT INTO auth.users (
id, instance_id, aud, role, email,
encrypted_password, email_confirmed_at,
raw_user_meta_data, created_at, updated_at
)
VALUES (
quota_user_id,
'00000000-0000-0000-0000-000000000000',
'authenticated',
'authenticated',
quota_email,
'encrypted',
now(),
'{"first_name": "Quota", "last_name": "User"}'::jsonb,
now(),
now()
);
END LOOP;
END $$;
SELECT is(
public.is_freemium_available(),
false,
'Freemium availability should be false when 100 free plan users exist'
);
select * from finish();
rollback;

View file

@ -902,7 +902,7 @@ export type Database = {
}
Enums: {
devis_status: "draft" | "sent" | "accepted" | "rejected" | "expired"
subscription_plan: "none" | "trial" | "standard" | "beta"
subscription_plan: "none" | "trial" | "standard" | "beta" | "free"
task_status: "todo" | "in_progress" | "in_review" | "done"
}
CompositeTypes: {
@ -1035,7 +1035,7 @@ export const Constants = {
public: {
Enums: {
devis_status: ["draft", "sent", "accepted", "rejected", "expired"],
subscription_plan: ["none", "trial", "standard", "beta"],
subscription_plan: ["none", "trial", "standard", "beta", "free"],
task_status: ["todo", "in_progress", "in_review", "done"],
},
},