This commit is contained in:
Arthur Belleville 2025-12-01 22:21:49 +01:00
parent 06f2ac541b
commit ce11d37a9d
No known key found for this signature in database
11 changed files with 411 additions and 60 deletions

View file

@ -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<typeof createSupabaseMock>, 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();
});
});

View file

@ -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", () => {

View file

@ -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

View file

@ -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<TabloInsert, "owner_id"> & {
const factory = createFactory<AuthEnv>();
const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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<typeof MiddlewareManage
});
});
const canCreateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>
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;
};

View file

@ -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> = T[];
@ -405,14 +407,14 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {
</ul>
<ul role="list" className={twMerge("mt-auto grid py-1", isCollapsed ? "pl-2.5 pr-3" : "")}>
{/* Trial upsell message */}
{shouldShowUpsell && !isCollapsed && (
{shouldShowTrialUpsell && !isCollapsed && (
<li className="mb-2">
<div
className={twMerge(
"mx-2 mb-2 p-3 rounded-lg border",
isUrgent
? "bg-gradient-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
: "bg-gradient-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
? "bg-linear-to-br from-red-50 to-orange-50 dark:from-red-950/20 dark:to-orange-950/20 border-red-200 dark:border-red-800"
: "bg-linear-to-br from-purple-50 to-blue-50 dark:from-purple-950/20 dark:to-blue-950/20 border-purple-200 dark:border-purple-800"
)}
>
<div className="flex items-start gap-2 mb-2">
@ -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"}
</p>
</div>
</div>
@ -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 }) {
</div>
</li>
)}
{/* Freemium upsell message */}
{shouldShowFreemiumUpsell && !isCollapsed && (
<li className="mb-2">
<div className="mx-2 mb-2 p-3 rounded-lg border bg-linear-to-br from-blue-50 to-cyan-50 dark:from-blue-950/20 dark:to-cyan-950/20 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-2 mb-2">
<Sparkles className="w-4 h-4 text-blue-600 dark:text-blue-400 shrink-0 mt-0.5" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-blue-900 dark:text-blue-100">
Plan Freemium
</p>
<p className="text-xs mt-0.5 text-blue-700 dark:text-blue-300">
Débloquez des tablos illimités en passant au plan Starter.
</p>
</div>
</div>
<Button
size="sm"
onClick={() =>
createCheckout({
priceId: STANDARD_MONTHLY_PRICE_ID,
successUrl: `${window.location.origin}?upgraded=true`,
cancelUrl: `${window.location.origin}?canceled=true`,
})
}
disabled={checkoutPending || !STANDARD_MONTHLY_PRICE_ID}
className="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 ? (
"..."
) : (
<>
<CreditCard className="w-3 h-3" />
Essayer Starter
</>
)}
</Button>
</div>
</li>
)}
{/* <li>
<NavLink isActive={location.pathname === "/support"}>
<RouterLink

View file

@ -18,6 +18,8 @@ import {
useTrialExpiration,
} from "../hooks/stripe";
import { useUser } from "../providers/UserStoreProvider";
import { pluralize } from "@xtablo/shared";
import { useMemo } from "react";
/**
* Subscription management card for Settings page
@ -34,6 +36,11 @@ export function SubscriptionCard() {
const { daysRemaining } = useTrialExpiration();
const daysRemainingValue = useMemo(() => {
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"}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
@ -152,7 +159,8 @@ export function SubscriptionCard() {
Accès gratuit pendant 7 jours
</p>
<p className="text-xs text-purple-700 dark:text-purple-300">
Il vous reste {daysRemaining} jours pour passer à Standard.
Il vous reste {daysRemainingValue} {pluralize("jour", daysRemainingValue)}{" "}
pour passer au plan Starter.
</p>
</div>
</div>
@ -175,7 +183,7 @@ export function SubscriptionCard() {
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Passer à Standard
Passer au plan Starter
</>
)}
</Button>
@ -195,10 +203,34 @@ export function SubscriptionCard() {
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éé.
Un seul tablo disponible gratuitement, passez au plan Starter pour profiter de
toutes les fonctionnalités.
</p>
</div>
</div>
<Button
onClick={() =>
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 ? (
<>
<Loader2Icon className="w-4 h-4 animate-spin" />
Chargement...
</>
) : (
<>
<CheckCircle2 className="w-4 h-4" />
Passer au plan Starter
</>
)}
</Button>
</div>
)}
@ -209,7 +241,7 @@ export function SubscriptionCard() {
<div className="space-y-3">
<div>
<p className="text-sm font-medium text-green-900 dark:text-green-100">
Plan Standard
Plan Starter
</p>
<p className="text-xs text-green-700 dark:text-green-300 mt-1">
Toutes les fonctionnalités débloquées
@ -273,7 +305,7 @@ export function SubscriptionCard() {
Abonnement en cours d'annulation
</p>
<p className="text-xs text-orange-700 dark:text-orange-300 mt-1">
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() {
)}
</p>
<p className="text-xs text-orange-600 dark:text-orange-400 mt-2">
Vous aurez accès aux fonctionnalités Standard jusqu'à cette date.
Vous aurez accès aux fonctionnalités Starter jusqu'à cette date.
</p>
</div>
</div>

View file

@ -76,13 +76,13 @@ export function TrialUpsellModal() {
</DialogTitle>
<DialogDescription className="text-center">
{isUrgent
? "Ne perdez pas l'accès à vos projets ! Passez au plan Standard pour continuer."
: "Profitez de toutes les fonctionnalités sans limite en passant au plan Standard."}
? "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."}
</DialogDescription>
</DialogHeader>
<div className="space-y-3 py-4">
<p className="text-sm font-medium">Avec Standard, vous bénéficiez de :</p>
<p className="text-sm font-medium">Avec Starter, vous bénéficiez de :</p>
<ul className="space-y-2">
{[
"Tablos et projets illimités",
@ -123,7 +123,7 @@ export function TrialUpsellModal() {
) : (
<>
<CreditCard className="w-4 h-4" />
Passer à Standard
Passer au plan Starter
</>
)}
</Button>

View file

@ -35,14 +35,14 @@ export function UpgradePanel() {
</div>
<CardTitle className="text-2xl">Votre période d'essai est terminée</CardTitle>
<CardDescription className="text-base">
Pour continuer à utiliser XTablo, passez au plan Standard et débloquez toutes les
Pour continuer à utiliser XTablo, passez au plan Starter et débloquez toutes les
fonctionnalités
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Features list */}
<div className="space-y-3">
<Text className="text-sm font-medium">Ce que vous obtenez avec Standard :</Text>
<Text className="text-sm font-medium">Ce que vous obtenez avec Starter :</Text>
<ul className="space-y-2">
{[
"Tablos illimités",
@ -80,7 +80,7 @@ export function UpgradePanel() {
) : (
<>
<CreditCard className="w-5 h-5" />
Passer à Standard
Passer au plan Starter
</>
)}
</Button>

View file

@ -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;
};

View file

@ -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 = (
<Button id="create-tablo-button" onClick={openCreateModal} disabled={isCreateDisabled}>
<Plus />
{createTabloMutation.isPending ? t("common:actions.saving") : t("pages:tablo.createButton")}
</Button>
);
if (!isReadOnly) {
return button;
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" role="presentation">
{button}
</span>
</TooltipTrigger>
<TooltipContent>
{isReadOnlyUser ? (
<p>Vous ne pouvez pas créer de tablo car vous êtes en mode lecture seule.</p>
) : (
<p>Vous ne pouvez pas créer de tablo car vous avez atteint votre limite de tablos.</p>
)}
</TooltipContent>
</Tooltip>
);
};
// Show loading state
if (isLoading) {
return (
@ -240,9 +276,7 @@ export const TabloPage = () => {
<h1 className="text-3xl font-bold text-foreground">{t("pages:tablo.title")}</h1>
<Text className="text-muted-foreground mt-1">{t("pages:tablo.subtitle")}</Text>
</div>
<Button onClick={openCreateModal} disabled={isReadOnly}>
<Plus /> Nouveau tablo
</Button>
{createTabloButton()}
</div>
</div>
</header>
@ -611,16 +645,7 @@ export const TabloPage = () => {
</button>
</div>
<Button
id="create-tablo-button"
onClick={openCreateModal}
disabled={createTabloMutation.isPending || isReadOnly}
>
<Plus />
{createTabloMutation.isPending
? t("common:actions.saving")
: t("pages:tablo.createButton")}
</Button>
{createTabloButton()}
</div>
</div>
</div>

View file

@ -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<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
);
}
const TooltipTrigger = TooltipPrimitive.Trigger;
function Tooltip({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
);
}
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-sm text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
function TooltipTrigger({ ...props }: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };