Onboarding + freemium
This commit is contained in:
parent
c72587e246
commit
06f2ac541b
12 changed files with 610 additions and 14 deletions
|
|
@ -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"
|
||||
|
|
|
|||
227
apps/main/src/components/OnboardingModal.tsx
Normal file
227
apps/main/src/components/OnboardingModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
67
apps/main/src/locales/en/onboarding.json
Normal file
67
apps/main/src/locales/en/onboarding.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
67
apps/main/src/locales/fr/onboarding.json
Normal file
67
apps/main/src/locales/fr/onboarding.json
Normal 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
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue