From ce11d37a9d6376d1ce05431dcd502086f5dd82d3 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Mon, 1 Dec 2025 22:21:49 +0100 Subject: [PATCH] Freemium --- .../api/src/__tests__/helpers/helpers.test.ts | 117 ++++++++++++++++++ apps/api/src/__tests__/routes/tablo.test.ts | 38 ++++++ apps/api/src/helpers/helpers.ts | 42 ++++++- apps/api/src/routers/tablo.ts | 15 ++- apps/main/src/components/NavigationBar.tsx | 57 +++++++-- apps/main/src/components/SubscriptionCard.tsx | 46 +++++-- apps/main/src/components/TrialUpsellModal.tsx | 8 +- apps/main/src/components/UpgradePanel.tsx | 6 +- apps/main/src/hooks/tablos.ts | 19 +++ apps/main/src/pages/tablo.tsx | 55 +++++--- packages/ui/src/components/tooltip.tsx | 68 +++++++--- 11 files changed, 411 insertions(+), 60 deletions(-) create mode 100644 apps/api/src/__tests__/helpers/helpers.test.ts diff --git a/apps/api/src/__tests__/helpers/helpers.test.ts b/apps/api/src/__tests__/helpers/helpers.test.ts new file mode 100644 index 0000000..d3fb848 --- /dev/null +++ b/apps/api/src/__tests__/helpers/helpers.test.ts @@ -0,0 +1,117 @@ +// @ts-nocheck +import type { Context, Next } from "hono"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { MAX_TABLO_LIMIT, verifyTabloLimitForUser } from "../../helpers/helpers.js"; + +const createSupabaseMock = ({ + profileData = { plan: "free" }, + profileError = null, + tabloCount = 0, + tabloError = null, +} = {}) => { + const profileSingle = vi.fn(async () => ({ data: profileData, error: profileError })); + const profileEq = vi.fn(() => ({ single: profileSingle })); + const profileSelect = vi.fn(() => ({ eq: profileEq })); + + const tabloEq = vi.fn(async () => ({ count: tabloCount, error: tabloError })); + const tabloSelect = vi.fn(() => ({ eq: tabloEq })); + + const from = vi.fn((table: string) => { + if (table === "profiles") { + return { select: profileSelect }; + } + if (table === "tablos") { + return { select: tabloSelect }; + } + throw new Error(`Unexpected table ${table}`); + }); + + return { + from, + profileSingle, + profileEq, + profileSelect, + tabloEq, + tabloSelect, + }; +}; + +const createContext = (supabase: ReturnType, user: { id: string }) => { + const json = vi.fn(); + const get = vi.fn((key: string) => { + if (key === "supabase") return supabase; + if (key === "user") return user; + return undefined; + }); + + return { + get, + json, + } as unknown as Context; +}; + +describe("verifyTabloLimitForUser", () => { + const user = { id: "test-user" }; + let next: Next; + + beforeEach(() => { + vi.clearAllMocks(); + next = vi.fn(async () => {}); + }); + + it("returns 500 when profile lookup fails", async () => { + const supabase = createSupabaseMock({ + profileData: null, + profileError: { message: "db down" }, + }); + const ctx = createContext(supabase, user); + + await verifyTabloLimitForUser(ctx, next); + + expect(ctx.json).toHaveBeenCalledWith({ error: "Failed to get user profile" }, 500); + expect(next).not.toHaveBeenCalled(); + }); + + it("denies free users that reached the tablo limit", async () => { + const supabase = createSupabaseMock({ + profileData: { plan: "free" }, + tabloCount: MAX_TABLO_LIMIT, + }); + const ctx = createContext(supabase, user); + + await verifyTabloLimitForUser(ctx, next); + + expect(ctx.json).toHaveBeenCalledWith( + { error: "You have reached your tablo limit" }, + 403 + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("allows free users below the limit to proceed", async () => { + const belowLimitCount = Math.max(0, MAX_TABLO_LIMIT - 1); + const supabase = createSupabaseMock({ + profileData: { plan: "free" }, + tabloCount: belowLimitCount, + }); + const ctx = createContext(supabase, user); + + await verifyTabloLimitForUser(ctx, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(ctx.json).not.toHaveBeenCalled(); + }); + + it("skips tablo count check for non-free plans", async () => { + const supabase = createSupabaseMock({ + profileData: { plan: "pro" }, + }); + const ctx = createContext(supabase, user); + + await verifyTabloLimitForUser(ctx, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(supabase.tabloSelect).not.toHaveBeenCalled(); + }); +}); + diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 639c24d..20cf223 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -185,6 +185,14 @@ describe("Tablo Endpoint", () => { }; describe("POST /tablos/create - Create Tablo", () => { + const supabaseAdmin = createClient(config.SUPABASE_URL, config.SUPABASE_SERVICE_ROLE_KEY, { + auth: { persistSession: false }, + }); + + beforeEach(async () => { + await supabaseAdmin.from("profiles").update({ plan: "standard" }).eq("id", ownerUser.userId); + }); + it("should allow owner to create a tablo and create a Stream Chat channel", async () => { const res = await createTabloRequest(ownerUser, client, { name: "New Owner Tablo", @@ -242,6 +250,36 @@ describe("Tablo Endpoint", () => { expect(res.status >= 400).toBeTruthy(); }); + + it("should block free plan users who reached the tablo limit", async () => { + const { data: profileData } = await supabaseAdmin + .from("profiles") + .select("plan") + .eq("id", ownerUser.userId) + .single(); + + const originalPlan = profileData?.plan ?? "standard"; + + await supabaseAdmin.from("profiles").update({ plan: "free" }).eq("id", ownerUser.userId); + + try { + const res = await createTabloRequest(ownerUser, client, { + name: "Free Limit Tablo", + status: "todo", + color: "#ABCDEF", + }); + + expect(res.status).toBe(403); + const data = await res.json(); + expect(data.error).toBe("You have reached your tablo limit"); + expect(mockChannelCreate).not.toHaveBeenCalled(); + } finally { + await supabaseAdmin + .from("profiles") + .update({ plan: originalPlan }) + .eq("id", ownerUser.userId); + } + }); }); describe("PATCH /tablos/update - Update Tablo", () => { diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 83a396f..3ee8e4c 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -1,11 +1,13 @@ import { ListObjectsV2Command, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import type { SupabaseClient } from "@supabase/supabase-js"; -import type { EventAndTablo } from "@xtablo/shared-types"; +import type { EventAndTablo, Tables } from "@xtablo/shared-types"; import type { Context, Next } from "hono"; import type { Transporter } from "nodemailer"; import type { StreamChat } from "stream-chat"; import { generatePassword } from "./token.js"; +export const MAX_TABLO_LIMIT = 1; + export const generateICSFromEvents = ( events: EventAndTablo[], calendarName: string = "Planning" @@ -176,6 +178,44 @@ export const checkTabloAdmin = async (c: Context, next: Next) => { await next(); }; +export const verifyTabloLimitForUser = async (c: Context, next: Next) => { + const supabase = c.get("supabase"); + const user = c.get("user"); + + // Get user profile to check subscription status + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); + + if (profileError) { + return c.json({ error: "Failed to get user profile" }, 500); + } + + const userProfile = profile as Tables<"profiles">; + + if (userProfile.plan === "free") { + const { count, error: countError } = await supabase + .from("tablos") + .select("id", { count: "exact" }) + .eq("owner_id", user.id); + + const tabloCount = count as number; + + if (countError) { + return c.json({ error: "Failed to check tablo count" }, 500); + } + + if (tabloCount >= MAX_TABLO_LIMIT) { + return c.json({ error: "You have reached your tablo limit" }, 403); + } + await next(); + } else { + await next(); + } +}; + /** * Creates a new invited user account and adds them to a tablo * @param supabase - Supabase client with admin privileges diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 4e6ca30..913aa07 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -2,7 +2,12 @@ import type { EventInsertInTablo, Tables, TabloInsert } from "@xtablo/shared-typ import { Hono } from "hono"; import { createFactory } from "hono/factory"; import type { AppConfig } from "../config.js"; -import { checkTabloAdmin, createInvitedUser, writeCalendarFileToR2 } from "../helpers/helpers.js"; +import { + checkTabloAdmin, + createInvitedUser, + verifyTabloLimitForUser, + writeCalendarFileToR2, +} from "../helpers/helpers.js"; import { generateToken } from "../helpers/token.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv } from "../types/app.types.js"; @@ -14,7 +19,7 @@ type PostTablo = Omit & { const factory = createFactory(); const createTablo = (middlewareManager: ReturnType) => - factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { + factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); const data = await c.req.json(); @@ -501,6 +506,11 @@ const generateWebcalUrl = (middlewareManager: ReturnType) => + factory.createHandlers(middlewareManager.regularUserCheck, verifyTabloLimitForUser, async (c) => { + return c.json({ canCreate: true }); + }); + export const getTabloRouter = (config: AppConfig) => { const tabloRouter = new Hono(); const middlewareManager = MiddlewareManager.getInstance(); @@ -517,6 +527,7 @@ export const getTabloRouter = (config: AppConfig) => { tabloRouter.get("/members/:tablo_id", ...getTabloMembers); tabloRouter.post("/leave", ...leaveTablo); tabloRouter.post("/webcal/generate-url", ...generateWebcalUrl(middlewareManager)); + tabloRouter.get("/can-create-tablo", ...canCreateTablo(middlewareManager)); return tabloRouter; }; diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index 925a28c..2a06f12 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -290,8 +290,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { 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; + // Show upsell when user is still in trial or using freemium tier + const shouldShowTrialUpsell = + daysRemaining !== null && user.plan === "none" && !user.is_temporary; + const shouldShowFreemiumUpsell = user.plan === "free" && !user.is_temporary; const isUrgent = daysRemaining !== null && daysRemaining <= 3; type List = T[]; @@ -405,14 +407,14 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
    {/* Trial upsell message */} - {shouldShowUpsell && !isCollapsed && ( + {shouldShowTrialUpsell && !isCollapsed && (
  • @@ -442,7 +444,7 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { : "text-purple-700 dark:text-purple-300" )} > - {isUrgent ? "Passez à Standard maintenant" : "Passez à Standard"} + {isUrgent ? "Essayer Starter maintenant" : "Essayer Starter"}

    @@ -459,8 +461,8 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { 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" + ? "bg-linear-to-r from-red-500 to-orange-500 hover:from-red-600 hover:to-orange-600" + : "bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600" )} > {checkoutPending ? ( @@ -475,6 +477,45 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
  • )} + {/* Freemium upsell message */} + {shouldShowFreemiumUpsell && !isCollapsed && ( +
  • +
    +
    + +
    +

    + Plan Freemium +

    +

    + Débloquez des tablos illimités en passant au plan Starter. +

    +
    +
    + +
    +
  • + )} {/*
  • { + if (!daysRemaining) return 7; + return daysRemaining; + }, [daysRemaining]); + const isPaying = user.plan === "trial" || user.plan === "standard"; const isBeta = user.plan === "beta"; const isFreemium = user.plan === "free"; @@ -109,7 +116,7 @@ export function SubscriptionCard() { ? "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"} + : "Passez à Starter pour débloquer toutes les fonctionnalités"} @@ -152,7 +159,8 @@ export function SubscriptionCard() { Accès gratuit pendant 7 jours

    - Il vous reste {daysRemaining} jours pour passer à Standard. + Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "} + pour passer au plan Starter.

    @@ -175,7 +183,7 @@ export function SubscriptionCard() { ) : ( <> - Passer à Standard + Passer au plan Starter )} @@ -195,10 +203,34 @@ export function SubscriptionCard() { Plan Freemium

    - Vous profitez d'un accès gratuit, un seul tablo ne peut être créé. + Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de + toutes les fonctionnalités.

    + )} @@ -209,7 +241,7 @@ export function SubscriptionCard() {

    - Plan Standard + Plan Starter

    Toutes les fonctionnalités débloquées @@ -273,7 +305,7 @@ export function SubscriptionCard() { Abonnement en cours d'annulation

    - Votre abonnement Standard sera annulé le{" "} + Votre abonnement Starter sera annulé le{" "} {subscription.current_period_end && new Date(subscription.current_period_end * 1000).toLocaleDateString( "fr-FR", @@ -285,7 +317,7 @@ export function SubscriptionCard() { )}

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

    diff --git a/apps/main/src/components/TrialUpsellModal.tsx b/apps/main/src/components/TrialUpsellModal.tsx index 9470942..f609d07 100644 --- a/apps/main/src/components/TrialUpsellModal.tsx +++ b/apps/main/src/components/TrialUpsellModal.tsx @@ -76,13 +76,13 @@ export function TrialUpsellModal() { {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."} + ? "Ne perdez pas l'accès à vos projets ! Passez au plan Starter pour continuer." + : "Profitez de toutes les fonctionnalités sans limite en passant au plan Starter."}
    -

    Avec Standard, vous bénéficiez de :

    +

    Avec Starter, vous bénéficiez de :

      {[ "Tablos et projets illimités", @@ -123,7 +123,7 @@ export function TrialUpsellModal() { ) : ( <> - Passer à Standard + Passer au plan Starter )} diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index a5c9293..b315c5f 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -35,14 +35,14 @@ export function UpgradePanel() {
    Votre période d'essai est terminée - Pour continuer à utiliser XTablo, passez au plan Standard et débloquez toutes les + Pour continuer à utiliser XTablo, passez au plan Starter et débloquez toutes les fonctionnalités {/* Features list */}
    - Ce que vous obtenez avec Standard : + Ce que vous obtenez avec Starter :
      {[ "Tablos illimités", @@ -80,7 +80,7 @@ export function UpgradePanel() { ) : ( <> - Passer à Standard + Passer au plan Starter )} diff --git a/apps/main/src/hooks/tablos.ts b/apps/main/src/hooks/tablos.ts index 207a3c6..a359b07 100644 --- a/apps/main/src/hooks/tablos.ts +++ b/apps/main/src/hooks/tablos.ts @@ -164,3 +164,22 @@ export const useGetAllTabloAccess = () => { }); return { data, isLoading, error }; }; + +export const useCanCreateTablo = () => { + const api = useAuthedApi(); + + const { data } = useQuery<{ canCreate: boolean }>({ + queryKey: ["can-create-tablo"], + queryFn: async () => { + try { + const { data } = await api.get<{ canCreate: boolean }>("/api/v1/tablos/can-create-tablo"); + + return data; + } catch { + return { canCreate: false }; + } + }, + }); + + return data?.canCreate; +}; diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index 381e92b..423e2a0 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -35,8 +35,9 @@ import { import { useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; -import { useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; +import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useIsReadOnlyUser } from "../providers/UserStoreProvider"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip"; type FilterOption = { id: "all" | "todo" | "inProgress" | "done"; @@ -56,7 +57,10 @@ export const TabloPage = () => { const [filterType, setFilterType] = useState<"all" | "todo" | "inProgress" | "done">("all"); const navigate = useNavigate(); const [searchParams, setSearchParams] = useSearchParams(); - const isReadOnly = useIsReadOnlyUser(); + const isReadOnlyUser = useIsReadOnlyUser(); + const canCreateTablo = useCanCreateTablo(); + + const isReadOnly = isReadOnlyUser || !canCreateTablo; // Get view mode from URL params, default to "list" const viewMode = (searchParams.get("view") as "grid" | "list") || "list"; @@ -229,6 +233,38 @@ export const TabloPage = () => { const kpis = calculateKPIs(); + const createTabloButton = () => { + const isCreateDisabled = createTabloMutation.isPending || isReadOnly; + + const button = ( + + ); + + if (!isReadOnly) { + return button; + } + + return ( + + + + {button} + + + + {isReadOnlyUser ? ( +

      Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.

      + ) : ( +

      Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.

      + )} +
      +
      + ); + }; + // Show loading state if (isLoading) { return ( @@ -240,9 +276,7 @@ export const TabloPage = () => {

      {t("pages:tablo.title")}

      {t("pages:tablo.subtitle")}
    - + {createTabloButton()} @@ -611,16 +645,7 @@ export const TabloPage = () => { - + {createTabloButton()} diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index b4b83af..b20ad09 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,29 +1,57 @@ "use client"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -import { cn } from "@xtablo/shared"; import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; -const TooltipProvider = TooltipPrimitive.Provider; +import { cn } from "@xtablo/shared"; -const Tooltip = TooltipPrimitive.Root; +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} -const TooltipTrigger = TooltipPrimitive.Trigger; +function Tooltip({ ...props }: React.ComponentProps) { + return ( + + + + ); +} -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; +function TooltipTrigger({ ...props }: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };