Freemium
This commit is contained in:
parent
06f2ac541b
commit
ce11d37a9d
11 changed files with 411 additions and 60 deletions
117
apps/api/src/__tests__/helpers/helpers.test.ts
Normal file
117
apps/api/src/__tests__/helpers/helpers.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
Loading…
Reference in a new issue