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.
+
+
+
+
+ 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 h-7 text-xs gap-1 bg-linear-to-r from-blue-500 to-cyan-500 hover:from-blue-600 hover:to-cyan-600"
+ >
+ {checkoutPending ? (
+ "..."
+ ) : (
+ <>
+
+ Essayer 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.
+
+ createCheckout({
+ priceId: STANDARD_MONTHLY_PRICE_ID,
+ successUrl: `${window.location.origin}/settings?success=true`,
+ cancelUrl: `${window.location.origin}/settings?canceled=true`,
+ })
+ }
+ disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
+ className="w-full gap-2 bg-gradient-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600"
+ >
+ {checkoutPending ? (
+ <>
+
+ Chargement...
+ >
+ ) : (
+ <>
+
+ Passer au plan Starter
+ >
+ )}
+
)}
@@ -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 = (
+
+
+ {createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
+
+ );
+
+ 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")}
-
- Nouveau tablo
-
+ {createTabloButton()}
@@ -611,16 +645,7 @@ export const TabloPage = () => {
-
-
- {createTabloMutation.isPending
- ? t("common:actions.saving")
- : t("pages:tablo.createButton")}
-
+ {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 };