From d0820ebbf147a8548f550ce226d4173ebc218886 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 8 Mar 2026 19:29:44 +0100 Subject: [PATCH 1/7] Implement solo teams founder billing --- apps/api/.env.production | 9 +- .../api/src/__tests__/helpers/billing.test.ts | 73 ++++ apps/api/src/helpers/billing.ts | 379 ++++++++++++++++++ apps/api/src/routers/stripe.ts | 332 ++++++++++++--- apps/api/src/routers/user.ts | 85 ++-- apps/main/.env.production | 7 +- apps/main/.env.staging | 8 +- apps/main/src/App.tsx | 2 + apps/main/src/components/NavigationBar.tsx | 29 +- .../src/components/PendingFounderCheckout.tsx | 49 +++ apps/main/src/components/SubscriptionCard.tsx | 161 ++++---- apps/main/src/components/TrialUpsellModal.tsx | 63 +-- apps/main/src/components/UpgradePanel.tsx | 76 +++- apps/main/src/hooks/auth.ts | 68 +++- apps/main/src/hooks/organization.ts | 8 + apps/main/src/hooks/stripe.ts | 106 +++-- apps/main/src/lib/billing.ts | 4 + apps/main/src/pages/settings.tsx | 6 +- apps/main/src/pages/signup-v2.tsx | 130 +++--- apps/main/src/pages/signup.test.tsx | 34 ++ apps/main/src/pages/signup.tsx | 48 ++- ...60308103000_map_founder_to_annual_plan.sql | 158 ++++++++ 22 files changed, 1490 insertions(+), 345 deletions(-) create mode 100644 apps/api/src/__tests__/helpers/billing.test.ts create mode 100644 apps/api/src/helpers/billing.ts create mode 100644 apps/main/src/components/PendingFounderCheckout.tsx create mode 100644 apps/main/src/lib/billing.ts create mode 100644 supabase/migrations/20260308103000_map_founder_to_annual_plan.sql diff --git a/apps/api/.env.production b/apps/api/.env.production index b9de357..ab3fd38 100644 --- a/apps/api/.env.production +++ b/apps/api/.env.production @@ -9,4 +9,11 @@ R2_ACCOUNT_ID="9715fa14c5e5d1612301572cf1c6bbee" TASKS_SECRET="gT3BAytmNwhe1wKmvgREBlWcqK0=" EMAIL_USER="baptiste@xtablo.com" -EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" \ No newline at end of file +EMAIL_CLIENT_ID="904332563417-e2n7pchtgnkrkp360baaebfeig55maig.apps.googleusercontent.com" + +STRIPE_SOLO_PRICE_ID=price_solo_placeholder +STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder +STRIPE_FOUNDER_PRICE_ID=price_founder_placeholder + +BILLING_TRIAL_DAYS=14 +BILLING_TRIAL_ROLLOUT_AT=2026-03-08T00:00:00.000Z diff --git a/apps/api/src/__tests__/helpers/billing.test.ts b/apps/api/src/__tests__/helpers/billing.test.ts new file mode 100644 index 0000000..6316b60 --- /dev/null +++ b/apps/api/src/__tests__/helpers/billing.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from "vitest"; +import { + getBillableMemberCount, + getOrganizationOwner, + getTrialWindow, +} from "../../helpers/billing.js"; + +describe("billing helpers", () => { + it("returns the earliest organization member as billing owner", () => { + const owner = getOrganizationOwner([ + { + id: "owner-user", + created_at: "2026-01-01T10:00:00.000Z", + is_temporary: false, + }, + { + id: "late-user", + created_at: "2026-01-02T10:00:00.000Z", + is_temporary: false, + }, + ]); + + expect(owner?.id).toBe("owner-user"); + }); + + it("excludes temporary users from billable seat count", () => { + const count = getBillableMemberCount([ + { + id: "user-1", + created_at: "2026-01-01T10:00:00.000Z", + is_temporary: false, + }, + { + id: "temp-1", + created_at: "2026-01-02T10:00:00.000Z", + is_temporary: true, + }, + { + id: "user-2", + created_at: "2026-01-03T10:00:00.000Z", + is_temporary: null, + }, + ]); + + expect(count).toBe(2); + }); + + it("uses rollout date as trial start for older organizations", () => { + const result = getTrialWindow({ + ownerCreatedAt: new Date("2025-12-01T00:00:00.000Z"), + now: new Date("2026-03-10T00:00:00.000Z"), + trialDays: 14, + rolloutAt: new Date("2026-03-08T00:00:00.000Z"), + }); + + expect(result.trialStartDate.toISOString()).toBe("2026-03-08T00:00:00.000Z"); + expect(result.trialEndDate.toISOString()).toBe("2026-03-22T00:00:00.000Z"); + expect(result.isTrialExpired).toBe(false); + }); + + it("uses owner creation date as trial start for new organizations", () => { + const result = getTrialWindow({ + ownerCreatedAt: new Date("2026-03-12T00:00:00.000Z"), + now: new Date("2026-03-28T00:00:00.000Z"), + trialDays: 14, + rolloutAt: new Date("2026-03-08T00:00:00.000Z"), + }); + + expect(result.trialStartDate.toISOString()).toBe("2026-03-12T00:00:00.000Z"); + expect(result.trialEndDate.toISOString()).toBe("2026-03-26T00:00:00.000Z"); + expect(result.isTrialExpired).toBe(true); + }); +}); diff --git a/apps/api/src/helpers/billing.ts b/apps/api/src/helpers/billing.ts new file mode 100644 index 0000000..3ad4866 --- /dev/null +++ b/apps/api/src/helpers/billing.ts @@ -0,0 +1,379 @@ +import type { SupabaseClient } from "@supabase/supabase-js"; + +export type BillingPlan = "solo" | "team" | "annual"; +export type RequiredBillingPlan = "solo" | "team"; + +type BillingProfileRow = { + id: string; + created_at: string | null; + is_temporary: boolean | null; +}; + +type StripeCustomerRow = { + id: string; + metadata: Record | null; +}; + +type StripeSubscriptionRow = { + id: string; + status: string | null; + created: number | null; + current_period_end: number | null; +}; + +type StripeSubscriptionItemRow = { + subscription: string; + price: string | null; + quantity: number | null; +}; + +type StripePriceRow = { + id: string; + lookup_key: string | null; + metadata: Record | null; + product: string | null; +}; + +type StripeProductRow = { + id: string; + name: string | null; + metadata: Record | null; +}; + +export type OrganizationBillingState = { + owner_user_id: string; + member_count: number; + trial_starts_at: string; + trial_ends_at: string; + is_trial_expired: boolean; + required_plan: RequiredBillingPlan; + required_team_quantity: number; + active_subscription_plan: BillingPlan | null; + active_subscription_quantity: number; +}; + +const ACTIVE_BILLING_STATUSES = ["active", "trialing", "past_due"]; +const DEFAULT_BILLING_TRIAL_DAYS = 14; + +const parseTrialDays = () => { + const parsed = Number.parseInt(process.env.BILLING_TRIAL_DAYS ?? "", 10); + if (Number.isNaN(parsed) || parsed <= 0) { + return DEFAULT_BILLING_TRIAL_DAYS; + } + + return parsed; +}; + +const parseTrialRolloutDate = () => { + const raw = process.env.BILLING_TRIAL_ROLLOUT_AT; + if (!raw) { + return null; + } + + const parsed = new Date(raw); + if (Number.isNaN(parsed.getTime())) { + return null; + } + + return parsed; +}; + +export const getOrganizationOwner = (profiles: BillingProfileRow[]) => profiles[0] ?? null; + +export const getBillableMemberCount = (profiles: BillingProfileRow[]) => + profiles.filter((profile) => profile.is_temporary !== true).length; + +export const getTrialWindow = (input: { + ownerCreatedAt: Date; + now: Date; + trialDays: number; + rolloutAt: Date | null; +}) => { + const { ownerCreatedAt, now, trialDays, rolloutAt } = input; + const trialStartDate = rolloutAt && rolloutAt > ownerCreatedAt ? rolloutAt : ownerCreatedAt; + const trialEndDate = new Date(trialStartDate); + trialEndDate.setUTCDate(trialEndDate.getUTCDate() + trialDays); + + return { + trialStartDate, + trialEndDate, + isTrialExpired: now.getTime() > trialEndDate.getTime(), + }; +}; + +const resolveRequiredPlan = (memberCount: number): RequiredBillingPlan => + memberCount <= 1 ? "solo" : "team"; + +const statusWeight = (status: string | null | undefined) => { + if (status === "active") return 3; + if (status === "past_due") return 2; + if (status === "trialing") return 1; + return 0; +}; + +const planWeight = (plan: BillingPlan | null) => { + if (plan === "annual") return 3; + if (plan === "team") return 2; + if (plan === "solo") return 1; + return 0; +}; + +const inferBillingPlan = (planHint: string | null | undefined): BillingPlan | null => { + const hint = (planHint ?? "").toLowerCase(); + + if ( + hint.includes("founder") || + hint.includes("annual") || + hint.includes("beta") || + hint.includes("infinite") + ) { + return "annual"; + } + + if (hint.includes("team") || hint.includes("standard")) { + return "team"; + } + + if (hint.includes("solo") || hint.includes("free")) { + return "solo"; + } + + return null; +}; + +const getPlanHint = (price: StripePriceRow | undefined, product: StripeProductRow | undefined) => { + const parts = [ + price?.metadata?.plan, + price?.metadata?.tier, + price?.lookup_key, + product?.metadata?.plan, + product?.metadata?.tier, + product?.name, + ] + .map((part) => part?.toLowerCase().trim()) + .filter((part): part is string => Boolean(part && part.length > 0)); + + return parts[0] ?? null; +}; + +const getOrganizationProfiles = async (supabase: SupabaseClient, organizationId: number) => { + const { data, error } = await supabase + .from("profiles") + .select("id, created_at, is_temporary") + .eq("organization_id", organizationId) + .order("created_at", { ascending: true }); + + if (error) { + return { error: error.message, profiles: null }; + } + + const profiles = (data ?? []) as BillingProfileRow[]; + if (profiles.length === 0) { + return { error: "Organization has no members", profiles: null }; + } + + return { error: null, profiles }; +}; + +const resolveActiveSubscription = async ( + supabase: SupabaseClient, + ownerUserId: string +): Promise<{ plan: BillingPlan | null; quantity: number }> => { + const { data: customers, error: customersError } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); + + if (customersError) { + throw new Error(customersError.message); + } + + const customer = (customers as StripeCustomerRow[] | null)?.find( + (candidate) => candidate.metadata?.user_id === ownerUserId + ); + + if (!customer) { + return { plan: null, quantity: 0 }; + } + + const { data: subscriptions, error: subscriptionsError } = await supabase + .schema("stripe") + .from("subscriptions") + .select("id, status, created, current_period_end") + .eq("customer", customer.id) + .in("status", ACTIVE_BILLING_STATUSES); + + if (subscriptionsError) { + throw new Error(subscriptionsError.message); + } + + const normalizedSubscriptions = (subscriptions ?? []) as StripeSubscriptionRow[]; + if (normalizedSubscriptions.length === 0) { + return { plan: null, quantity: 0 }; + } + + const subscriptionIds = normalizedSubscriptions.map((subscription) => subscription.id); + const { data: subscriptionItems, error: subscriptionItemsError } = await supabase + .schema("stripe") + .from("subscription_items") + .select("subscription, price, quantity") + .in("subscription", subscriptionIds); + + if (subscriptionItemsError) { + throw new Error(subscriptionItemsError.message); + } + + const normalizedItems = (subscriptionItems ?? []) as StripeSubscriptionItemRow[]; + const priceIds = Array.from( + new Set(normalizedItems.map((item) => item.price).filter((price): price is string => !!price)) + ); + + let pricesById = new Map(); + let productsById = new Map(); + + if (priceIds.length > 0) { + const { data: prices, error: pricesError } = await supabase + .schema("stripe") + .from("prices") + .select("id, lookup_key, metadata, product") + .in("id", priceIds); + + if (pricesError) { + throw new Error(pricesError.message); + } + + const normalizedPrices = (prices ?? []) as StripePriceRow[]; + pricesById = new Map(normalizedPrices.map((price) => [price.id, price])); + + const productIds = Array.from( + new Set( + normalizedPrices + .map((price) => price.product) + .filter((product): product is string => Boolean(product)) + ) + ); + + if (productIds.length > 0) { + const { data: products, error: productsError } = await supabase + .schema("stripe") + .from("products") + .select("id, name, metadata") + .in("id", productIds); + + if (productsError) { + throw new Error(productsError.message); + } + + const normalizedProducts = (products ?? []) as StripeProductRow[]; + productsById = new Map(normalizedProducts.map((product) => [product.id, product])); + } + } + + const candidates = normalizedSubscriptions.flatMap((subscription) => { + const relatedItems = normalizedItems.filter((item) => item.subscription === subscription.id); + + if (relatedItems.length === 0) { + return [ + { + subscription, + plan: null as BillingPlan | null, + quantity: 1, + }, + ]; + } + + return relatedItems.map((item) => { + const price = item.price ? pricesById.get(item.price) : undefined; + const product = price?.product ? productsById.get(price.product) : undefined; + const hint = getPlanHint(price, product); + + return { + subscription, + plan: inferBillingPlan(hint), + quantity: Math.max(1, item.quantity ?? 1), + }; + }); + }); + + if (candidates.length === 0) { + return { plan: "team", quantity: 1 }; + } + + candidates.sort((a, b) => { + const aPlan = a.plan ?? "team"; + const bPlan = b.plan ?? "team"; + + const byPlanWeight = planWeight(bPlan) - planWeight(aPlan); + if (byPlanWeight !== 0) return byPlanWeight; + + const byStatusWeight = + statusWeight(b.subscription.status) - statusWeight(a.subscription.status); + if (byStatusWeight !== 0) return byStatusWeight; + + const aPeriodEnd = a.subscription.current_period_end ?? a.subscription.created ?? 0; + const bPeriodEnd = b.subscription.current_period_end ?? b.subscription.created ?? 0; + return bPeriodEnd - aPeriodEnd; + }); + + const winner = candidates[0]; + return { + // Backward-compatible default for unknown paid subscriptions. + plan: winner.plan ?? "team", + quantity: winner.quantity, + }; +}; + +export const getOrganizationBillingState = async ( + supabase: SupabaseClient, + organizationId: number, + nowInput: Date = new Date() +): Promise<{ data: OrganizationBillingState | null; error: string | null }> => { + const { profiles, error: profilesError } = await getOrganizationProfiles( + supabase, + organizationId + ); + if (profilesError || !profiles) { + return { data: null, error: profilesError ?? "Failed to load organization members" }; + } + + const owner = getOrganizationOwner(profiles); + if (!owner) { + return { data: null, error: "Organization has no members" }; + } + + const ownerCreatedAt = owner.created_at ? new Date(owner.created_at) : nowInput; + const trialWindow = getTrialWindow({ + ownerCreatedAt, + now: new Date(nowInput), + trialDays: parseTrialDays(), + rolloutAt: parseTrialRolloutDate(), + }); + + const memberCount = getBillableMemberCount(profiles); + const requiredPlan = resolveRequiredPlan(memberCount); + + try { + const activeSubscription = await resolveActiveSubscription(supabase, owner.id); + + return { + data: { + owner_user_id: owner.id, + member_count: memberCount, + trial_starts_at: trialWindow.trialStartDate.toISOString(), + trial_ends_at: trialWindow.trialEndDate.toISOString(), + is_trial_expired: trialWindow.isTrialExpired, + required_plan: requiredPlan, + required_team_quantity: requiredPlan === "team" ? Math.max(1, memberCount) : 1, + active_subscription_plan: activeSubscription.plan, + active_subscription_quantity: activeSubscription.quantity, + }, + error: null, + }; + } catch (error) { + return { + data: null, + error: error instanceof Error ? error.message : "Failed to resolve billing state", + }; + } +}; diff --git a/apps/api/src/routers/stripe.ts b/apps/api/src/routers/stripe.ts index daa6204..7d115c4 100644 --- a/apps/api/src/routers/stripe.ts +++ b/apps/api/src/routers/stripe.ts @@ -1,10 +1,162 @@ import { Hono } from "hono"; import { createFactory } from "hono/factory"; -import Stripe from "stripe"; import type { AppConfig } from "../config.js"; +import { getOrganizationBillingState } from "../helpers/billing.js"; import { MiddlewareManager } from "../middlewares/middleware.js"; import type { AuthEnv, BaseEnv } from "../types/app.types.js"; +const DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST = new Set([ + "arbelleville@gmail.com", + "baptiste.belleville74@gmail.com", + "hugo@xtablo.com", +]); + +const BILLING_OWNER_ONLY_ERROR = "Only the organization billing owner can manage billing"; + +type CheckoutPlan = "solo" | "team" | "founder"; + +type StripePriceRow = { + id: string; + unit_amount: number | null; + lookup_key: string | null; + metadata: Record | null; +}; + +type CreateCheckoutSessionBody = { + plan?: CheckoutPlan; + priceId?: string; + price_id?: string; + successUrl?: string; + success_url?: string; + cancelUrl?: string; + cancel_url?: string; +}; + +const parseCsvList = (value: string | undefined): string[] => + (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + +const getInfinitePriceAllowlist = (): Set => { + const configuredAllowlist = parseCsvList(process.env.STRIPE_INFINITE_ALLOWED_EMAILS).map( + (email) => email.toLowerCase() + ); + + return configuredAllowlist.length > 0 + ? new Set(configuredAllowlist) + : DEFAULT_INFINITE_SUBSCRIPTION_ALLOWLIST; +}; + +const getInfinitePriceIds = (): Set => { + const configuredIds = [ + ...parseCsvList(process.env.STRIPE_INFINITE_PRICE_IDS), + ...(process.env.STRIPE_INFINITE_PRICE_ID ? [process.env.STRIPE_INFINITE_PRICE_ID] : []), + ].map((id) => id.trim()); + + return new Set(configuredIds.filter((id) => id.length > 0)); +}; + +const isInfinitePrice = (price: StripePriceRow): boolean => { + const configuredPriceIds = getInfinitePriceIds(); + if (configuredPriceIds.size > 0) { + return configuredPriceIds.has(price.id); + } + + const lookupKey = price.lookup_key?.toLowerCase() ?? ""; + const metadataPlan = price.metadata?.plan?.toLowerCase() ?? ""; + const metadataTier = price.metadata?.tier?.toLowerCase() ?? ""; + + // Backward-compatible fallback for existing infinite setup. + return ( + lookupKey === "infinite" || + lookupKey === "infinite_subscription" || + metadataPlan === "infinite" || + metadataTier === "infinite" || + price.unit_amount === 0 + ); +}; + +const getPlanPriceId = (plan: CheckoutPlan): string | null => { + if (plan === "solo") { + return process.env.STRIPE_SOLO_PRICE_ID ?? null; + } + + if (plan === "team") { + return process.env.STRIPE_TEAM_MONTHLY_PRICE_ID ?? null; + } + + return process.env.STRIPE_FOUNDER_PRICE_ID ?? null; +}; + +const findStripeCustomerByUserId = async ( + supabase: AuthEnv["Variables"]["supabase"], + userId: string +) => { + const { data: customers, error } = await supabase + .schema("stripe") + .from("customers") + .select("id, metadata") + .limit(1000); + + if (error) { + throw new Error(error.message); + } + + return ( + ( + customers as Array<{ id: string; metadata: Record | null }> | null + )?.find((customer) => customer.metadata?.user_id === userId) ?? null + ); +}; + +const resolveBillingOwnerContext = async ( + supabase: AuthEnv["Variables"]["supabase"], + userId: string +) => { + const { data: profile, error: profileError } = await supabase + .from("profiles") + .select("organization_id") + .eq("id", userId) + .single(); + + if (profileError || !profile?.organization_id) { + return { error: "Failed to resolve your organization", data: null }; + } + + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + profile.organization_id + ); + + if (billingError || !billingState) { + return { error: "Failed to resolve organization billing state", data: null }; + } + + return { + error: null, + data: { + organizationId: profile.organization_id, + billingState, + isBillingOwner: billingState.owner_user_id === userId, + }, + }; +}; + +const getCheckoutPrice = async ( + supabase: AuthEnv["Variables"]["supabase"], + priceId: string +): Promise => { + const { data: price } = await supabase + .schema("stripe") + .from("prices") + .select("id, unit_amount, lookup_key, metadata") + .eq("id", priceId) + .maybeSingle(); + + return (price as StripePriceRow | null) ?? null; +}; + const webhookFactory = createFactory(); /** @@ -61,83 +213,107 @@ const createCheckoutSession = ( const supabase = c.get("supabase"); const stripe = c.get("stripe"); - const body = await c.req.json(); - const { priceId, successUrl, cancelUrl } = body; + const body = (await c.req.json()) as CreateCheckoutSessionBody; + const plan = body.plan; + const legacyPriceId = body.priceId ?? body.price_id; + const successUrl = body.successUrl ?? body.success_url; + const cancelUrl = body.cancelUrl ?? body.cancel_url; + + if (!plan && !legacyPriceId) { + return c.json({ error: "plan or priceId is required" }, 400); + } + + if (plan && !["solo", "team", "founder"].includes(plan)) { + return c.json({ error: "Invalid plan" }, 400); + } + + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); + + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + let priceId = legacyPriceId ?? null; + let quantity = 1; + + if (plan) { + priceId = getPlanPriceId(plan); + if (!priceId) { + return c.json({ error: `Missing Stripe price configuration for plan: ${plan}` }, 500); + } + + if (plan === "team") { + quantity = Math.max(1, ownerContext.billingState.member_count); + } + } if (!priceId) { return c.json({ error: "priceId is required" }, 400); } - const { data: price } = await supabase - .schema("stripe") - .from("prices") - .select("*") - .eq("id", priceId) - .maybeSingle(); + const price = await getCheckoutPrice(supabase, priceId); if (!price) { return c.json({ error: "Price not found" }, 404); } - const allowedInfiniteUsers = [ - "arbelleville@gmail.com", - "baptiste.belleville74@gmail.com", - "hugo@xtablo.com", - ]; - - if (price.unit_amount === 0 && !allowedInfiniteUsers.includes(user.email!)) { - return c.json({ error: "This price is not available" }, 400); + const normalizedEmail = user.email?.toLowerCase() ?? ""; + const isRestrictedInfinitePrice = isInfinitePrice(price); + const infinitePriceAllowlist = getInfinitePriceAllowlist(); + if (isRestrictedInfinitePrice && !infinitePriceAllowlist.has(normalizedEmail)) { + return c.json({ error: "This price is not available" }, 403); } try { - // Get or create Stripe customer - let customerId: string; + const ownerUserId = ownerContext.billingState.owner_user_id; + const ownerCustomer = await findStripeCustomerByUserId(supabase, ownerUserId); - // Check if customer already exists by querying stripe schema with metadata filter - // Note: Using service role, so we filter manually by metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata") - .limit(1000); // Get all customers to filter by metadata + let customerId = ownerCustomer?.id ?? ""; + if (!customerId) { + if (!user.email) { + return c.json({ error: "Billing owner email is required" }, 400); + } - const existingCustomer = customers?.find( - (c: Stripe.Customer) => c.metadata?.user_id === user.id - ); - - if (existingCustomer) { - customerId = existingCustomer.id; - } else { - // Create new Stripe customer with user_id in metadata - // stripe-sync-engine will automatically sync this to the database via webhook + // Create new Stripe customer with user_id in metadata. + // stripe-sync-engine syncs this customer to Supabase via webhook. const customer = await stripe.customers.create({ - email: user.email!, + email: user.email, metadata: { - user_id: user.id, // Stored in metadata for tracking + user_id: ownerUserId, }, }); customerId = customer.id; } - // Create Checkout Session const session = await stripe.checkout.sessions.create({ customer: customerId, line_items: [ { price: priceId, - quantity: 1, + quantity, }, ], mode: "subscription", success_url: successUrl || `${config.XTABLO_URL}/settings?success=true`, cancel_url: cancelUrl || `${config.XTABLO_URL}/settings?canceled=true`, metadata: { - user_id: user.id, + user_id: ownerUserId, + organization_id: String(ownerContext.organizationId), + checkout_plan: plan ?? "legacy_price", }, subscription_data: { metadata: { - user_id: user.id, + user_id: ownerUserId, + organization_id: String(ownerContext.organizationId), + checkout_plan: plan ?? "legacy_price", }, }, }); @@ -166,16 +342,26 @@ const createPortalSession = ( const stripe = c.get("stripe"); const body = await c.req.json(); - const { returnUrl } = body; + const returnUrl = body.returnUrl ?? body.return_url; + + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); + + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } try { - // Get Stripe customer ID by filtering metadata - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); - - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "No Stripe customer found" }, 404); @@ -210,14 +396,24 @@ const cancelSubscription = (middlewareManager: ReturnType c.metadata?.user_id === user.id); + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + try { + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "Customer not found" }, 404); @@ -265,14 +461,24 @@ const reactivateSubscription = ( const supabase = c.get("supabase"); const stripe = c.get("stripe"); - try { - // Get user's Stripe customer first - const { data: customers } = await supabase - .schema("stripe") - .from("customers") - .select("id, metadata"); + const { data: ownerContext, error: ownerError } = await resolveBillingOwnerContext( + supabase, + user.id + ); - const customer = customers?.find((c: Stripe.Customer) => c.metadata?.user_id === user.id); + if (ownerError || !ownerContext) { + return c.json({ error: ownerError ?? "Failed to resolve billing owner" }, 500); + } + + if (!ownerContext.isBillingOwner) { + return c.json({ error: BILLING_OWNER_ONLY_ERROR }, 403); + } + + try { + const customer = await findStripeCustomerByUserId( + supabase, + ownerContext.billingState.owner_user_id + ); if (!customer) { return c.json({ error: "No subscription found to reactivate" }, 404); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 4fabbe1..34f1d2e 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -2,12 +2,8 @@ import { DeleteObjectsCommand, ListObjectsV2Command, PutObjectCommand } from "@a import type { Tables } from "@xtablo/shared-types"; import { Hono } from "hono"; import { createFactory } from "hono/factory"; -import { - createInvitedUser, - getOrganizationPlan, - MAX_TABLO_LIMIT, - MAX_TEAM_MEMBER_LIMIT, -} from "../helpers/helpers.js"; +import { getOrganizationBillingState } from "../helpers/billing.js"; +import { createInvitedUser, getOrganizationPlan, MAX_TABLO_LIMIT } from "../helpers/helpers.js"; import type { AuthEnv } from "../types/app.types.js"; const factory = createFactory(); @@ -317,6 +313,14 @@ const getOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Failed to resolve organization plan" }, 500); } + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + organizationId + ); + if (billingError || !billingState) { + return c.json({ error: "Failed to resolve organization billing state" }, 500); + } + return c.json({ organization: { id: organization.id, @@ -326,6 +330,14 @@ const getOrganization = factory.createHandlers(async (c) => { tablo_count: tabloCount || 0, }, members: members || [], + trial_starts_at: billingState.trial_starts_at, + trial_ends_at: billingState.trial_ends_at, + is_trial_expired: billingState.is_trial_expired, + required_plan: billingState.required_plan, + required_team_quantity: billingState.required_team_quantity, + active_subscription_plan: billingState.active_subscription_plan, + active_subscription_quantity: billingState.active_subscription_quantity, + is_billing_owner: billingState.owner_user_id === user.id, }); }); @@ -398,10 +410,12 @@ const inviteToOrganization = factory.createHandlers(async (c) => { } const organizationId = senderProfile.organization_id; - const { plan, error: planError } = await getOrganizationPlan(supabase, organizationId); - - if (planError) { - return c.json({ error: "Failed to resolve organization plan" }, 500); + const { data: billingState, error: billingError } = await getOrganizationBillingState( + supabase, + organizationId + ); + if (billingError || !billingState) { + return c.json({ error: "Failed to resolve organization billing state" }, 500); } const { data: existingUser, error: existingUserError } = await supabase @@ -422,31 +436,32 @@ const inviteToOrganization = factory.createHandlers(async (c) => { return c.json({ error: "This email already belongs to another organization" }, 409); } - const { count: membersCount, error: membersCountError } = await supabase - .from("profiles") - .select("id", { count: "exact", head: true }) - .eq("organization_id", organizationId); + if (billingState.is_trial_expired && billingState.active_subscription_plan !== "annual") { + const requiredSeatsForInvite = billingState.member_count + 1; - if (membersCountError) { - return c.json({ error: "Failed to check organization size" }, 500); - } + if (billingState.active_subscription_plan === "team") { + if (billingState.active_subscription_quantity < requiredSeatsForInvite) { + return c.json( + { + error: + "Your Teams subscription does not have enough seats for this invite. Please increase seats in billing and try again.", + }, + 403 + ); + } + } else { + const noSubscriptionMessage = + billingState.required_plan === "solo" + ? "Your trial has ended. Solo allows 1 member only. Upgrade to Teams to invite more members." + : "Your trial has ended. An active Teams subscription is required to invite members. Please subscribe or increase seats in billing."; - if (plan === "solo" && (membersCount || 0) >= 1) { - return c.json( - { - error: `Solo plan allows a single user only. Upgrade to Team to invite collaborators.`, - }, - 403 - ); - } - - if (plan === "team" && (membersCount || 0) >= MAX_TEAM_MEMBER_LIMIT) { - return c.json( - { - error: `Team plan allows up to ${MAX_TEAM_MEMBER_LIMIT} users. Upgrade to Annual to invite more.`, - }, - 403 - ); + return c.json( + { + error: noSubscriptionMessage, + }, + 403 + ); + } } const invitedUser = await createInvitedUser( @@ -533,9 +548,9 @@ const inviteToOrganization = factory.createHandlers(async (c) => { return c.json({ message: "Invitation sent successfully", limits: { - plan, + plan: billingState.active_subscription_plan ?? billingState.required_plan, max_tablos_for_solo: MAX_TABLO_LIMIT, - max_members_for_team: MAX_TEAM_MEMBER_LIMIT, + required_team_quantity_for_next_invite: billingState.member_count + 1, }, }); }); diff --git a/apps/main/.env.production b/apps/main/.env.production index 44de9ae..da8e895 100644 --- a/apps/main/.env.production +++ b/apps/main/.env.production @@ -10,4 +10,9 @@ VITE_STRIPE_PUBLISHABLE_KEY=pk_live_51Qc159AmcXPHW4mTHUTW6it2mdZ3KQTxZGXZ188DKpX VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SO0HAAmcXPHW4mTkFIh3CvF VITE_STRIPE_INFINITE_PRICE_ID=price_1SXHp8AmcXPHW4mTbus6j4Za -VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app \ No newline at end of file +VITE_API_URL=https://xablo-api-636270553187.europe-west1.run.app +VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_team_placeholder + +VITE_STRIPE_FOUNDER_PRICE_ID=price_annual_placeholder + +VITE_STRIPE_SOLO_PRICE_ID=price_solo_placeholder diff --git a/apps/main/.env.staging b/apps/main/.env.staging index 067d257..8fdd5e7 100644 --- a/apps/main/.env.staging +++ b/apps/main/.env.staging @@ -9,4 +9,10 @@ VITE_STREAM_CHAT_API_KEY="t5vvvddteapa" VITE_STRIPE_PUBLISHABLE_KEY=pk_test_51SPKLPAto3YQ7YhIrM5ViAUXWuSwKJeHyOyOINVg9cnwxxOcbMlyhxQcDYWDSLNQJukafxbc7kqpkGI82lFezaiM00rgcALKB0 VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID=price_1SPr3qAto3YQ7YhIALNeFBva -VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app \ No newline at end of file +VITE_API_URL=https://xablo-api-staging-636270553187.europe-west1.run.app + +VITE_STRIPE_SOLO_PRICE_ID=price_1T8iT4Ato3YQ7YhIYCIIk0RA +VITE_STRIPE_TEAM_MONTHLY_PRICE_ID=price_1T8hZfAto3YQ7YhIRK9YUSub +VITE_STRIPE_FOUNDER_PRICE_ID=price_1T8hawAto3YQ7YhIrVyKHggH + +VITE_STRIPE_INFINITE_PRICE_ID=price_infinite_placeholder diff --git a/apps/main/src/App.tsx b/apps/main/src/App.tsx index 6fbbe7d..c2d2ebd 100644 --- a/apps/main/src/App.tsx +++ b/apps/main/src/App.tsx @@ -3,6 +3,7 @@ import { ThemeProvider } from "@xtablo/shared/contexts/ThemeContext"; import { Toaster } from "@xtablo/ui/components/sonner"; import { BrowserRouter as Router, useRoutes } from "react-router-dom"; import { CookieBanner } from "./components/CookieBanner"; +import { PendingFounderCheckout } from "./components/PendingFounderCheckout"; import { TrialUpsellModal } from "./components/TrialUpsellModal"; import { UpgradePanel } from "./components/UpgradePanel"; import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext"; @@ -29,6 +30,7 @@ const Routes = () => { return ( + {appElement} diff --git a/apps/main/src/components/NavigationBar.tsx b/apps/main/src/components/NavigationBar.tsx index c174544..390a2cb 100644 --- a/apps/main/src/components/NavigationBar.tsx +++ b/apps/main/src/components/NavigationBar.tsx @@ -47,7 +47,8 @@ import { useTranslation } from "react-i18next"; import { Link as RouterLink, useLocation } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { useLogout } from "../hooks/auth"; -import { normalizeBillingPlan, useCreateCheckoutSession } from "../hooks/stripe"; +import { useOrganization } from "../hooks/organization"; +import { useCreateCheckoutSession } from "../hooks/stripe"; import { useTablosList } from "../hooks/tablos"; import { isProd, isStaging } from "../lib/env"; import { useIsReadOnlyUser, useUser } from "../providers/UserStoreProvider"; @@ -378,16 +379,18 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { const user = useUser(); const { t } = useTranslation("navigation"); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); + const { data: organizationData } = useOrganization(); - const TEAM_MONTHLY_PRICE_ID = - import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID || - import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || - ""; + const hasCompliantTeamPlan = + organizationData?.active_subscription_plan === "annual" || + (organizationData?.active_subscription_plan === "team" && + organizationData.active_subscription_quantity >= organizationData.required_team_quantity); - const currentPlan = normalizeBillingPlan(user.plan); - - // Show upsell when organization is on Solo plan - const shouldShowSoloUpsell = currentPlan === "solo" && !user.is_temporary; + const shouldShowSoloUpsell = + !user.is_temporary && + !!organizationData && + organizationData.required_plan === "team" && + !hasCompliantTeamPlan; type List = T[]; @@ -530,10 +533,10 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) {

- Plan Solo + Plan Teams

- Passez au plan Team pour inviter jusqu'à 3 utilisateurs. + Ajoutez des sièges Teams pour inviter des membres.

@@ -541,12 +544,12 @@ export function MainNavigation({ isCollapsed }: { isCollapsed: boolean }) { size="sm" onClick={() => createCheckout({ - priceId: TEAM_MONTHLY_PRICE_ID, + plan: "team", successUrl: `${window.location.origin}?upgraded=true`, cancelUrl: `${window.location.origin}?canceled=true`, }) } - disabled={checkoutPending || !TEAM_MONTHLY_PRICE_ID} + disabled={checkoutPending} className="w-full h-7 text-xs gap-1 bg-linear-to-r from-purple-500 to-blue-500 hover:from-purple-600 hover:to-blue-600" > {checkoutPending ? ( diff --git a/apps/main/src/components/PendingFounderCheckout.tsx b/apps/main/src/components/PendingFounderCheckout.tsx new file mode 100644 index 0000000..e607f74 --- /dev/null +++ b/apps/main/src/components/PendingFounderCheckout.tsx @@ -0,0 +1,49 @@ +import { useEffect, useRef } from "react"; +import { useOrganization } from "../hooks/organization"; +import { useCreateCheckoutSession } from "../hooks/stripe"; +import { PENDING_BILLING_CHECKOUT_PLAN_KEY } from "../lib/billing"; +import { useMaybeUser } from "../providers/UserStoreProvider"; + +export function PendingFounderCheckout() { + const user = useMaybeUser(); + const { data: organizationData, isLoading } = useOrganization(); + const { mutate: createCheckout } = useCreateCheckoutSession(); + const hasTriggered = useRef(false); + + useEffect(() => { + if (hasTriggered.current) return; + if (!user || isLoading || !organizationData) return; + + const pendingCheckout = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + if (pendingCheckout !== "founder") { + return; + } + + if (organizationData.active_subscription_plan === "annual") { + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + return; + } + + if (!organizationData.is_billing_owner) { + return; + } + + hasTriggered.current = true; + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + createCheckout( + { + plan: "founder", + successUrl: `${window.location.origin}/settings?success=true`, + cancelUrl: `${window.location.origin}/settings?canceled=true`, + }, + { + onError: () => { + localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder"); + hasTriggered.current = false; + }, + } + ); + }, [createCheckout, isLoading, organizationData, user]); + + return null; +} diff --git a/apps/main/src/components/SubscriptionCard.tsx b/apps/main/src/components/SubscriptionCard.tsx index 0070923..818bc36 100644 --- a/apps/main/src/components/SubscriptionCard.tsx +++ b/apps/main/src/components/SubscriptionCard.tsx @@ -8,6 +8,7 @@ import { CardTitle, } from "@xtablo/ui/components/card"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; +import { useOrganization } from "../hooks/organization"; import { normalizeBillingPlan, useCancelSubscription, @@ -18,18 +19,12 @@ import { } from "../hooks/stripe"; import { useUser } from "../providers/UserStoreProvider"; -const allowedInfiniteUsers = [ - "arbelleville@gmail.com", - "baptiste.belleville74@gmail.com", - "hugo@xtablo.com", -]; - /** - * Subscription management card for Settings page - * Shows current subscription status and allows users to upgrade/manage + * Subscription management card for Settings page. */ export function SubscriptionCard() { const user = useUser(); + const { data: organizationData } = useOrganization(); const { data: subscription, isLoading: subscriptionLoading } = useSubscription(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); const { mutate: openPortal, isPending: portalPending } = useCreatePortalSession(); @@ -37,33 +32,22 @@ export function SubscriptionCard() { const { mutate: reactivateSubscription, isPending: reactivatePending } = useReactivateSubscription(); - const normalizedPlan = normalizeBillingPlan(user.plan); - const isPaying = normalizedPlan === "team" || normalizedPlan === "annual"; - const isAnnual = normalizedPlan === "annual"; - const isSolo = normalizedPlan === "solo"; + const fallbackPlan = normalizeBillingPlan(user.plan); + const activePlan = organizationData?.active_subscription_plan ?? fallbackPlan; + const isBillingOwner = organizationData?.is_billing_owner ?? false; - // Replace with your actual price ID from Stripe Dashboard + const isPaying = activePlan === "team" || activePlan === "annual"; + const isFounder = activePlan === "annual"; - const infinitePriceId = import.meta.env.VITE_STRIPE_INFINITE_PRICE_ID || ""; - const teamPriceId = - import.meta.env.VITE_STRIPE_TEAM_MONTHLY_PRICE_ID || - import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || - ""; - const annualPriceId = import.meta.env.VITE_STRIPE_ANNUAL_PRICE_ID || ""; - const soloPriceId = import.meta.env.VITE_STRIPE_SOLO_PRICE_ID || ""; - - const priceId = - allowedInfiniteUsers.includes(user.email!) && infinitePriceId - ? infinitePriceId - : teamPriceId || annualPriceId || soloPriceId; + const requiredPlan = organizationData?.required_plan ?? "solo"; + const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1; const getStatusBadge = () => { - // Annual plan badge - if (isAnnual) { + if (isFounder) { return ( - Annual + Founder ); } @@ -72,7 +56,7 @@ export function SubscriptionCard() { return ( - Gratuit + Sans abonnement ); } @@ -119,11 +103,11 @@ export function SubscriptionCard() { {getStatusBadge()} - {isAnnual - ? "Vous disposez du plan Annual avec limites illimitées" + {isFounder + ? "Vous disposez du plan Founder (annuel) avec limites illimitées" : isPaying ? "Gérez votre abonnement et votre facturation" - : "Passez au plan Team ou Annual pour travailler en équipe"} + : "Choisissez Solo, Teams ou Founder"} @@ -133,76 +117,96 @@ export function SubscriptionCard() { ) : ( <> - {/* Annual Plan */} - {isAnnual && ( + {isFounder && (

- Plan Annual + Plan Founder (annuel)

- Utilisateurs et tablos illimités + Utilisateurs et tablos illimités.

-
-

- Votre organisation bénéficie des capacités maximales. -

-
)} - {isSolo && ( -
+ {!isFounder && ( +
-
-

- Plan Solo -

-

- 1 utilisateur et 10 tablos maximum pour votre organisation. +

+ Plan recommandé: {requiredPlan === "team" ? "Teams" : "Solo"} +

+

+ {requiredPlan === "team" + ? `Votre organisation nécessite ${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""} Teams.` + : "Votre organisation peut continuer avec Solo (1 utilisateur, 10 tablos actifs)."} +

+
+ + {isBillingOwner ? ( +
+ + + +
+ ) : ( +
+ +

+ Seul le propriétaire de facturation de l'organisation peut modifier + l'abonnement.

-
- + )}
)} - {/* Standard Tier - Active */} - {isPaying && subscription && !subscription.cancel_at_period_end && ( + {isPaying && subscription && !subscription.cancel_at_period_end && isBillingOwner && (

- Plan {isAnnual ? "Annual" : "Team"} + Plan {isFounder ? "Founder" : "Teams"}

Collaboration d'organisation activée @@ -254,8 +258,7 @@ export function SubscriptionCard() {

)} - {/* Standard Tier - Canceling */} - {isPaying && subscription?.cancel_at_period_end && ( + {isPaying && subscription?.cancel_at_period_end && isBillingOwner && (
@@ -266,7 +269,7 @@ export function SubscriptionCard() { Abonnement en cours d'annulation

- Votre abonnement {isAnnual ? "Annual" : "Team"} sera annulé le{" "} + Votre abonnement {isFounder ? "Founder" : "Teams"} sera annulé le{" "} {subscription.current_period_end && new Date(subscription.current_period_end * 1000).toLocaleDateString( "fr-FR", diff --git a/apps/main/src/components/TrialUpsellModal.tsx b/apps/main/src/components/TrialUpsellModal.tsx index f609d07..d74ab24 100644 --- a/apps/main/src/components/TrialUpsellModal.tsx +++ b/apps/main/src/components/TrialUpsellModal.tsx @@ -9,6 +9,7 @@ import { } from "@xtablo/ui/components/dialog"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; import { useEffect, useState } from "react"; +import { useOrganization } from "../hooks/organization"; import { useCreateCheckoutSession, useTrialExpiration } from "../hooks/stripe"; import { useMaybeUser } from "../providers/UserStoreProvider"; @@ -16,19 +17,23 @@ const MODAL_INTERVAL_MS = 15 * 60 * 1000; // 15 minutes const LAST_SHOWN_KEY = "trial-upsell-modal-last-shown"; /** - * Auto-opening modal that shows every 15 minutes to remind users about trial expiration - * Only shows if daysRemaining is not null (user is in trial period) + * Auto-opening modal that shows every 15 minutes to remind users about trial expiration. */ export function TrialUpsellModal() { const [isOpen, setIsOpen] = useState(false); const { daysRemaining } = useTrialExpiration(); + const { data: organizationData } = useOrganization(); const user = useMaybeUser(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); - const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || ""; + const shouldShowModal = + !!organizationData && + !organizationData.is_trial_expired && + !organizationData.active_subscription_plan && + !user?.is_temporary; - // Only show modal for users in trial period (not beta, not paid, and daysRemaining exists) - const shouldShowModal = daysRemaining !== null && user?.plan === "none" && !user?.is_temporary; + const requiredPlan = organizationData?.required_plan ?? "solo"; + const checkoutPlan = requiredPlan === "team" ? "team" : "solo"; useEffect(() => { if (!shouldShowModal) return; @@ -37,22 +42,19 @@ export function TrialUpsellModal() { const lastShown = localStorage.getItem(LAST_SHOWN_KEY); const now = Date.now(); - if (!lastShown || now - parseInt(lastShown) >= MODAL_INTERVAL_MS) { + if (!lastShown || now - Number.parseInt(lastShown, 10) >= MODAL_INTERVAL_MS) { setIsOpen(true); localStorage.setItem(LAST_SHOWN_KEY, now.toString()); } }; - // Check immediately on mount checkAndShowModal(); - // Set up interval to check every 15 minutes const interval = setInterval(checkAndShowModal, MODAL_INTERVAL_MS); - return () => clearInterval(interval); }, [shouldShowModal]); - if (!shouldShowModal) { + if (!shouldShowModal || daysRemaining === null) { return null; } @@ -75,22 +77,35 @@ export function TrialUpsellModal() { : `${daysRemaining} jour${daysRemaining > 1 ? "s" : ""} restants dans votre période d'essai`} - {isUrgent - ? "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."} + {requiredPlan === "team" + ? "Après l'essai, votre organisation passera sur Teams (facturation par siège)." + : "Après l'essai, vous pourrez continuer avec Solo."}

-

Avec Starter, vous bénéficiez de :

+

+ {requiredPlan === "team" + ? "Avec Teams, vous bénéficiez de :" + : "Avec Solo, vous bénéficiez de :"} +

    - {[ - "Tablos et projets illimités", - "Planification avancée", - "Chat en temps réel", - "Stockage de fichiers", - "Support prioritaire", - ].map((feature) => ( + {(requiredPlan === "team" + ? [ + "Utilisateurs multiples (par siège)", + "Tablos illimités", + "Planification avancée", + "Chat en temps réel", + "Support prioritaire", + ] + : [ + "1 utilisateur", + "10 tablos actifs", + "Planification avancée", + "Chat en temps réel", + "Support prioritaire", + ] + ).map((feature) => (
  • {feature} @@ -106,13 +121,13 @@ export function TrialUpsellModal() { diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index 0a85310..3506f36 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -10,6 +10,7 @@ import { Text } from "@xtablo/ui/components/typography"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; import { useUpgradeBlock } from "../contexts/UpgradeBlockContext"; import { useLogout } from "../hooks/auth"; +import { useOrganization } from "../hooks/organization"; import { useCreateCheckoutSession } from "../hooks/stripe"; /** @@ -20,13 +21,22 @@ export function UpgradePanel() { const { isBlocked } = useUpgradeBlock(); const { mutate: createCheckout, isPending: checkoutPending } = useCreateCheckoutSession(); const { mutate: signOut } = useLogout(); - - const STANDARD_MONTHLY_PRICE_ID = import.meta.env.VITE_STRIPE_STANDARD_MONTHLY_PRICE_ID || ""; + const { data: organizationData } = useOrganization(); if (!isBlocked) { return null; } + const requiredPlan = organizationData?.required_plan ?? "solo"; + const isBillingOwner = organizationData?.is_billing_owner ?? false; + const requiredTeamQuantity = organizationData?.required_team_quantity ?? 1; + const checkoutPlan = requiredPlan === "team" ? "team" : "solo"; + + const checkoutLabel = + checkoutPlan === "team" + ? `Passer au plan Teams (${requiredTeamQuantity} siège${requiredTeamQuantity > 1 ? "s" : ""})` + : "Passer au plan Solo"; + return (
    @@ -37,22 +47,34 @@ export function UpgradePanel() {
    Votre période d'essai est terminée - Pour continuer à utiliser XTablo, passez au plan Starter et débloquez toutes les - fonctionnalités + Pour continuer à utiliser XTablo, activez votre abonnement{" "} + {requiredPlan === "team" ? "Teams" : "Solo"}. - {/* Features list */}
    - Ce que vous obtenez avec Starter : + + {requiredPlan === "team" + ? "Ce que vous obtenez avec Teams :" + : "Ce que vous obtenez avec Solo :"} +
      - {[ - "Tablos illimités", - "Planification avancée", - "Chat en temps réel", - "Stockage de fichiers", - "Support prioritaire", - ].map((feature) => ( + {(requiredPlan === "team" + ? [ + "Utilisateurs multiples (facturation par siège)", + "Tablos illimités", + "Chat en temps réel", + "Stockage de fichiers", + "Support prioritaire", + ] + : [ + "1 utilisateur", + "10 tablos actifs", + "Chat en temps réel", + "Stockage de fichiers", + "Support prioritaire", + ] + ).map((feature) => (
    • {feature} @@ -61,17 +83,16 @@ export function UpgradePanel() {
    - {/* Upgrade button */}
    - {!STANDARD_MONTHLY_PRICE_ID && ( + + + {!isBillingOwner && (
    - Configuration Stripe requise. Veuillez contacter le support. + Seul le propriétaire de facturation de l'organisation peut modifier + l'abonnement.
    )}
    - {/* Help text */}
    Des questions ?{" "} diff --git a/apps/main/src/hooks/auth.ts b/apps/main/src/hooks/auth.ts index 8e909e3..61371af 100644 --- a/apps/main/src/hooks/auth.ts +++ b/apps/main/src/hooks/auth.ts @@ -7,6 +7,11 @@ import { useState } from "react"; import { useNavigate } from "react-router-dom"; import { match } from "ts-pattern"; import { api } from "../lib/api"; +import { + PENDING_BILLING_CHECKOUT_PLAN_KEY, + SIGNUP_BILLING_INTENT_KEY, + SignupBillingIntent, +} from "../lib/billing"; import { supabase } from "../lib/supabase"; export type User = SupabaseUser & { @@ -26,6 +31,7 @@ interface SignUpData { first_name: string; last_name: string; business_name: string; + billing_intent?: SignupBillingIntent; } interface LoginData { @@ -57,6 +63,7 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { first_name: data.first_name, last_name: data.last_name, business_name: data.business_name, + billing_intent: data.billing_intent ?? "trial", }, }, }); @@ -68,18 +75,71 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { } return response; }, - onSuccess: (data) => { + onSuccess: async (data, variables) => { + const wantsFounderCheckout = variables.billing_intent === "founder"; + + if (wantsFounderCheckout) { + localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, "founder"); + localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder"); + } else { + localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY); + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + } + // If there's no session, it means email confirmation is required if (!data.user?.email_confirmed_at) { // Store the email for the confirmation page localStorage.setItem("pendingConfirmationEmail", data.user?.email || ""); navigate("/confirm-email"); - } else if (redirectUrl) { + return; + } + + if (wantsFounderCheckout && data.session?.access_token) { + try { + const checkoutResponse = await api.post( + "/api/v1/stripe/create-checkout-session", + { + plan: "founder", + successUrl: `${window.location.origin}/settings?success=true`, + cancelUrl: `${window.location.origin}/settings?canceled=true`, + }, + { + headers: { + Authorization: `Bearer ${data.session.access_token}`, + }, + } + ); + + if (checkoutResponse.data?.url) { + localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY); + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + window.location.href = checkoutResponse.data.url; + return; + } + } catch (error) { + console.error("Founder checkout bootstrap failed after signup:", error); + toast.add({ + title: "Paiement Founder", + description: + "Votre compte est créé, mais la redirection vers le paiement a échoué. Vous pourrez finaliser le paiement dans l'application.", + type: "warning", + position: "top-center", + }); + } + } + + localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY); + if (!wantsFounderCheckout) { + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + } + + if (redirectUrl) { localStorage.removeItem("redirectUrl"); navigate(decodeURIComponent(redirectUrl)); - } else { - navigate("/"); + return; } + + navigate("/"); }, onError: (error) => { const errMap: Record = {}; diff --git a/apps/main/src/hooks/organization.ts b/apps/main/src/hooks/organization.ts index c61f980..d567a8c 100644 --- a/apps/main/src/hooks/organization.ts +++ b/apps/main/src/hooks/organization.ts @@ -25,6 +25,14 @@ export interface OrganizationMember { export interface OrganizationResponse { organization: OrganizationSummary; members: OrganizationMember[]; + trial_starts_at: string; + trial_ends_at: string; + is_trial_expired: boolean; + required_plan: "solo" | "team"; + required_team_quantity: number; + active_subscription_plan: "solo" | "team" | "annual" | null; + active_subscription_quantity: number; + is_billing_owner: boolean; } export const useOrganization = () => { diff --git a/apps/main/src/hooks/stripe.ts b/apps/main/src/hooks/stripe.ts index 3ba71ba..6d1d0d1 100644 --- a/apps/main/src/hooks/stripe.ts +++ b/apps/main/src/hooks/stripe.ts @@ -1,4 +1,3 @@ -import { loadStripe } from "@stripe/stripe-js"; import { useMutation, useQuery } from "@tanstack/react-query"; import { queryClient, @@ -9,13 +8,12 @@ import { } from "@xtablo/shared"; import { useMemo } from "react"; import { supabase } from "../lib/supabase"; -import { useMaybeUser, useUser } from "../providers/UserStoreProvider"; +import { useUser } from "../providers/UserStoreProvider"; import { useAuthedApi } from "./auth"; - -// Initialize Stripe -const stripePromise = loadStripe(import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY || ""); +import { useOrganization } from "./organization"; export type BillingPlan = "solo" | "team" | "annual"; +export type CheckoutPlan = "solo" | "team" | "founder"; export const normalizeBillingPlan = (plan: string | null | undefined): BillingPlan => { if (!plan) return "solo"; @@ -25,6 +23,38 @@ export const normalizeBillingPlan = (plan: string | null | undefined): BillingPl return "solo"; }; +const hasCompliantPaidPlan = (input: { + required_plan: "solo" | "team"; + required_team_quantity: number; + active_subscription_plan: "solo" | "team" | "annual" | null; + active_subscription_quantity: number; +}) => { + const { + required_plan, + required_team_quantity, + active_subscription_plan, + active_subscription_quantity, + } = input; + + if (!active_subscription_plan) { + return false; + } + + if (active_subscription_plan === "annual") { + return true; + } + + if (required_plan === "solo") { + return active_subscription_plan === "solo" || active_subscription_plan === "team"; + } + + if (active_subscription_plan !== "team") { + return false; + } + + return active_subscription_quantity >= required_team_quantity; +}; + /** * Hook to get user's subscription status from Supabase * Uses RPC function to access stripe data without modifying stripe schema @@ -73,29 +103,52 @@ export function useIsPayingUser() { } export const useIsPastTrial = () => { - const user = useMaybeUser(); + const { data: organizationData, isLoading } = useOrganization(); - // Trial model is deprecated in favor of explicit billing plans. - // Keep this hook returning false to avoid blocking existing users unexpectedly. const isPastTrial = useMemo(() => { - void user; - return false; - }, [user]); + if (!organizationData) { + return false; + } - return { isPastTrial, isLoading: !user }; + if (!organizationData.is_trial_expired) { + return false; + } + + return !hasCompliantPaidPlan({ + required_plan: organizationData.required_plan, + required_team_quantity: organizationData.required_team_quantity, + active_subscription_plan: organizationData.active_subscription_plan, + active_subscription_quantity: organizationData.active_subscription_quantity, + }); + }, [organizationData]); + + return { isPastTrial, isLoading }; }; /** - * Hook to get when the user's trial expires - * Returns the trial expiration date (creation date + 7 days) + * Hook to get when the organization's trial expires + * Returns remaining trial days when trial is active, otherwise null. */ export const useTrialExpiration = () => { - const user = useMaybeUser(); + const { data: organizationData, isLoading } = useOrganization(); - // Trial model is deprecated in favor of explicit billing plans. - const daysRemaining = useMemo(() => null as number | null, [user]); + const daysRemaining = useMemo(() => { + if (!organizationData || organizationData.is_trial_expired) { + return null as number | null; + } - return { daysRemaining, isLoading: !user }; + const trialEnd = new Date(organizationData.trial_ends_at).getTime(); + const now = Date.now(); + + const diffMs = trialEnd - now; + if (diffMs <= 0) { + return 0; + } + + return Math.ceil(diffMs / (1000 * 60 * 60 * 24)); + }, [organizationData]); + + return { daysRemaining, isLoading }; }; /** @@ -149,15 +202,22 @@ export function useCreateCheckoutSession() { const { mutate, isPending } = useMutation({ mutationFn: async ({ + plan, priceId, successUrl, cancelUrl, }: { - priceId: string; + plan?: CheckoutPlan; + priceId?: string; successUrl?: string; cancelUrl?: string; }) => { + if (!plan && !priceId) { + throw new Error("A checkout plan or priceId is required"); + } + const response = await api.post("/api/v1/stripe/create-checkout-session", { + plan, priceId, successUrl, cancelUrl, @@ -167,12 +227,6 @@ export function useCreateCheckoutSession() { throw new Error("No checkout URL returned"); } - // Redirect to Stripe Checkout - const stripe = await stripePromise; - if (!stripe) { - throw new Error("Stripe failed to load"); - } - // Use the URL directly for redirect window.location.href = response.data.url; @@ -246,6 +300,7 @@ export function useCancelSubscription() { queryClient.invalidateQueries({ queryKey: ["subscription"] }); queryClient.invalidateQueries({ queryKey: ["is-paying"] }); queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); }, onError: (error: Error) => { toast.add({ @@ -281,6 +336,7 @@ export function useReactivateSubscription() { queryClient.invalidateQueries({ queryKey: ["subscription"] }); queryClient.invalidateQueries({ queryKey: ["is-paying"] }); queryClient.invalidateQueries({ queryKey: ["user"] }); + queryClient.invalidateQueries({ queryKey: ["organization"] }); }, onError: (error: Error) => { toast.add({ diff --git a/apps/main/src/lib/billing.ts b/apps/main/src/lib/billing.ts new file mode 100644 index 0000000..2073fde --- /dev/null +++ b/apps/main/src/lib/billing.ts @@ -0,0 +1,4 @@ +export type SignupBillingIntent = "trial" | "founder"; + +export const SIGNUP_BILLING_INTENT_KEY = "signupBillingIntent"; +export const PENDING_BILLING_CHECKOUT_PLAN_KEY = "pendingBillingCheckoutPlan"; diff --git a/apps/main/src/pages/settings.tsx b/apps/main/src/pages/settings.tsx index 44502a0..74ef094 100644 --- a/apps/main/src/pages/settings.tsx +++ b/apps/main/src/pages/settings.tsx @@ -66,6 +66,10 @@ export default function SettingsPage() { const [organizationName, setOrganizationName] = useState(""); const [inviteEmail, setInviteEmail] = useState(""); const fileInputRef = useRef(null); + const displayedOrganizationPlan = + organizationData?.organization?.plan === "annual" + ? "founder" + : organizationData?.organization?.plan; useEffect(() => { if (organizationData?.organization?.name) { @@ -329,7 +333,7 @@ export default function SettingsPage() { />

    {t("settings:organization.currentPlan", { - plan: organizationData?.organization?.plan || "solo", + plan: displayedOrganizationPlan || "solo", })}

    diff --git a/apps/main/src/pages/signup-v2.tsx b/apps/main/src/pages/signup-v2.tsx index a56e68d..97bc18e 100644 --- a/apps/main/src/pages/signup-v2.tsx +++ b/apps/main/src/pages/signup-v2.tsx @@ -5,17 +5,12 @@ import { Button } from "@xtablo/ui/components/button"; import { FieldError } from "@xtablo/ui/components/field"; import { Input } from "@xtablo/ui/components/input"; import { Label } from "@xtablo/ui/components/label"; -import { - ArrowLeftIcon, - MonitorIcon, - MoonIcon, - SparklesIcon, - SunIcon, -} from "lucide-react"; +import { ArrowLeftIcon, MonitorIcon, MoonIcon, SparklesIcon, SunIcon } from "lucide-react"; import { useState } from "react"; import { useTranslation } from "react-i18next"; import { Link } from "react-router-dom"; import { useSignUp } from "../hooks/auth"; +import type { SignupBillingIntent } from "../lib/billing"; export function SignUpV2Page() { const { t } = useTranslation(["auth", "common"]); @@ -35,6 +30,7 @@ export function SignUpV2Page() { business_name: "", }); const [termsAccepted, setTermsAccepted] = useState(false); + const [billingIntent, setBillingIntent] = useState("trial"); const { theme, setTheme } = useTheme(); @@ -101,6 +97,7 @@ export function SignUpV2Page() { last_name: formData.last_name, confirm_password: formData.confirmPassword, business_name: formData.business_name, + billing_intent: billingIntent, }); }; @@ -132,27 +129,14 @@ export function SignUpV2Page() {

    - Xtablo - Xtablo + Xtablo + Xtablo
    -

    - {t("auth:signup.title")} -

    +

    {t("auth:signup.title")}

    {t("auth:signup.alreadyAccount")}{" "} - + {t("auth:signup.loginLink")}

    @@ -161,97 +145,76 @@ export function SignUpV2Page() {
    - setFormData({ ...formData, first_name: e.target.value }) - } + onChange={(e) => setFormData({ ...formData, first_name: e.target.value })} required placeholder={t("auth:signup.firstNamePlaceholder")} className="h-11" /> - {errors?.first_name && ( - - )} + {errors?.first_name && }
    - setFormData({ ...formData, last_name: e.target.value }) - } + onChange={(e) => setFormData({ ...formData, last_name: e.target.value })} required placeholder={t("auth:signup.lastNamePlaceholder")} className="h-11" /> - {errors?.last_name && ( - - )} + {errors?.last_name && }
    - setFormData({ ...formData, email: e.target.value }) - } + onChange={(e) => setFormData({ ...formData, email: e.target.value })} required placeholder={t("auth:signup.emailPlaceholder")} className="h-11" /> - {errors?.email && ( - - )} + {errors?.email && }
    - setFormData({ ...formData, password: e.target.value }) - } + onChange={(e) => setFormData({ ...formData, password: e.target.value })} required placeholder={t("auth:signup.passwordPlaceholder")} className="h-11" /> - {errors?.password && ( - - )} + {errors?.password && }
    +
    + +
    + + +
    +
    +
    -
    - {errors?.terms && ( - - )} + {errors?.terms && }
    @@ -358,9 +348,7 @@ export function SignUpV2Page() {
    -

    - {t("auth:signup.asideTitle")} -

    +

    {t("auth:signup.asideTitle")}

    {t("auth:signup.asideDescription")}

    diff --git a/apps/main/src/pages/signup.test.tsx b/apps/main/src/pages/signup.test.tsx index 79af006..4105406 100644 --- a/apps/main/src/pages/signup.test.tsx +++ b/apps/main/src/pages/signup.test.tsx @@ -193,10 +193,44 @@ describe("SignUpPage", () => { last_name: "Doe", confirm_password: "password123", business_name: "", + billing_intent: "trial", }); }); }); + it("submits founder billing intent when selected", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /founder/i })); + + const submitButton = screen.getByRole("button", { name: /create my account/i }); + const termsCheckbox = screen.getByRole("checkbox", { name: /i accept/i }); + + fireEvent.change(screen.getByPlaceholderText(/your first name/i), { + target: { value: "John" }, + }); + fireEvent.change(screen.getByPlaceholderText(/your last name/i), { + target: { value: "Doe" }, + }); + fireEvent.change(screen.getByPlaceholderText(/your email/i), { + target: { value: "john@example.com" }, + }); + fireEvent.change(screen.getAllByPlaceholderText(/your password/i)[0], { + target: { value: "password123" }, + }); + fireEvent.change(screen.getByPlaceholderText(/confirm your password/i), { + target: { value: "password123" }, + }); + fireEvent.click(termsCheckbox); + fireEvent.click(submitButton); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith( + expect.objectContaining({ billing_intent: "founder" }) + ); + }); + }); + it("requires terms checkbox to be checked", () => { renderWithProviders(); diff --git a/apps/main/src/pages/signup.tsx b/apps/main/src/pages/signup.tsx index 6f620d0..b4eec28 100644 --- a/apps/main/src/pages/signup.tsx +++ b/apps/main/src/pages/signup.tsx @@ -8,13 +8,13 @@ import { Label } from "@xtablo/ui/components/label"; import { MonitorIcon, MoonIcon, SunIcon } from "lucide-react"; import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate } from "react-router-dom"; +import { Link } from "react-router-dom"; import { twMerge } from "tailwind-merge"; import { useSignUp } from "../hooks/auth"; +import type { SignupBillingIntent } from "../lib/billing"; export function SignUpPage() { const { t } = useTranslation(["auth", "common"]); - const navigate = useNavigate(); const redirectUrl = localStorage.getItem("redirectUrl"); const { mutate: signUp, isPending } = useSignUp({ redirectUrl: redirectUrl ?? null, @@ -31,6 +31,7 @@ export function SignUpPage() { business_name: "", }); const [termsAccepted, setTermsAccepted] = useState(false); + const [billingIntent, setBillingIntent] = useState("trial"); // 3D Parallax effect const cardRef = useRef(null); @@ -137,14 +138,12 @@ export function SignUpPage() { last_name: formData.last_name, confirm_password: formData.confirmPassword, business_name: formData.business_name, + billing_intent: billingIntent, }); }; return ( -
    navigate("/")} - > +
    e.stopPropagation()} > {/* Glow effect behind the card */}
    @@ -324,6 +322,42 @@ export function SignUpPage() { )}
    +
    + +
    + + +
    +
    +
    profile.plan mapping so Founder plans +-- are normalized to internal annual semantics. + +CREATE OR REPLACE FUNCTION public.update_profile_subscription_status() RETURNS trigger +LANGUAGE plpgsql +SECURITY DEFINER +AS $$ +DECLARE + v_user_id uuid; + v_customer_id text; + v_plan_hint text; + v_has_paid_subscription boolean := FALSE; + v_plan public.subscription_plan := 'solo'; +BEGIN + -- Resolve customer id from the changed stripe row + IF TG_TABLE_NAME = 'subscriptions' THEN + v_customer_id := NEW.customer; + ELSIF TG_TABLE_NAME = 'subscription_items' THEN + SELECT s.customer + INTO v_customer_id + FROM stripe.subscriptions s + WHERE s.id = NEW.subscription; + ELSE + RETURN NEW; + END IF; + + IF v_customer_id IS NULL THEN + RETURN NEW; + END IF; + + -- Resolve application user id from Stripe customer metadata + SELECT (c.metadata->>'user_id')::uuid + INTO v_user_id + FROM stripe.customers c + WHERE c.id = v_customer_id; + + IF v_user_id IS NULL THEN + RETURN NEW; + END IF; + + -- Pick the best active/trialing subscription for this user and infer plan from + -- price/product metadata or lookup key. Fall back to Team when paid but unmapped. + WITH candidate_subscriptions AS ( + SELECT + s.status::text AS status, + lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) AS plan_hint, + CASE s.status::text + WHEN 'active' THEN 3 + WHEN 'past_due' THEN 2 + WHEN 'trialing' THEN 1 + ELSE 0 + END AS status_weight, + CASE + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%founder%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%annual%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%beta%' THEN 3 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%team%' OR lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%standard%' THEN 2 + WHEN lower( + coalesce( + NULLIF(pr.metadata->>'plan', ''), + NULLIF(pr.lookup_key, ''), + NULLIF(pd.metadata->>'plan', ''), + NULLIF(pd.name, '') + ) + ) LIKE '%solo%' THEN 1 + ELSE 0 + END AS plan_weight + FROM stripe.subscriptions s + JOIN stripe.customers c + ON c.id = s.customer + LEFT JOIN stripe.subscription_items si + ON si.subscription = s.id + LEFT JOIN stripe.prices pr + ON pr.id = si.price + LEFT JOIN stripe.products pd + ON pd.id = pr.product + WHERE (c.metadata->>'user_id')::uuid = v_user_id + AND s.status::text IN ('active', 'past_due', 'trialing') + AND ( + si.current_period_end IS NULL + OR to_timestamp(si.current_period_end) > now() + ) + ) + SELECT + TRUE, + cs.plan_hint + INTO v_has_paid_subscription, v_plan_hint + FROM candidate_subscriptions cs + ORDER BY cs.plan_weight DESC, cs.status_weight DESC + LIMIT 1; + + IF v_has_paid_subscription THEN + IF v_plan_hint LIKE '%founder%' OR v_plan_hint LIKE '%annual%' OR v_plan_hint LIKE '%beta%' THEN + v_plan := 'annual'; + ELSIF v_plan_hint LIKE '%team%' OR v_plan_hint LIKE '%standard%' THEN + v_plan := 'team'; + ELSIF v_plan_hint LIKE '%solo%' OR v_plan_hint LIKE '%free%' THEN + v_plan := 'solo'; + ELSE + -- paid but unmapped => Team by default (backward compatibility) + v_plan := 'team'; + END IF; + ELSE + v_plan := 'solo'; + END IF; + + UPDATE public.profiles + SET plan = v_plan + WHERE id = v_user_id; + + RETURN NEW; +END; +$$; + +ALTER FUNCTION public.update_profile_subscription_status() OWNER TO postgres; + +COMMENT ON FUNCTION public.update_profile_subscription_status() IS +'Maps Stripe subscription state to profile.plan and normalizes founder/annual to annual.'; From 03e426dd23cbd57718ad8cf0a5b53772fafae071 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 8 Mar 2026 21:11:42 +0100 Subject: [PATCH 2/7] Implement new billing model --- apps/api/src/helpers/helpers.ts | 16 +++++++++++++++- apps/api/src/routers/invite.ts | 3 ++- apps/api/src/routers/tablo.ts | 18 +++++++++--------- apps/api/src/routers/user.ts | 27 +++++++++++++++++++++++---- 4 files changed, 49 insertions(+), 15 deletions(-) diff --git a/apps/api/src/helpers/helpers.ts b/apps/api/src/helpers/helpers.ts index 0f9f636..4b0833b 100644 --- a/apps/api/src/helpers/helpers.ts +++ b/apps/api/src/helpers/helpers.ts @@ -291,8 +291,12 @@ export const createInvitedUser = async ( streamServerClient: StreamChat, transporter: Transporter, recipientEmail: string, - senderEmail: string + senderEmail: string, + options?: { + isTemporary?: boolean; + } ): Promise<{ success: boolean; error?: string; userId?: string }> => { + const isTemporary = options?.isTemporary ?? true; const xtabloUrl = process.env.XTABLO_URL || "https://app.xtablo.com"; // Create a new user account for the invited email @@ -318,6 +322,16 @@ export const createInvitedUser = async ( return { success: false, error: createUserError.message }; } + const { error: updateProfileError } = await supabase + .from("profiles") + .update({ is_temporary: isTemporary }) + .eq("id", newUser.user.id); + + if (updateProfileError) { + console.error("Error setting invited user temporary status:", updateProfileError); + return { success: false, error: updateProfileError.message }; + } + await streamServerClient.upsertUser({ id: newUser.user.id, name: recipientEmail.split("@")[0], diff --git a/apps/api/src/routers/invite.ts b/apps/api/src/routers/invite.ts index 28c8a37..b48c440 100644 --- a/apps/api/src/routers/invite.ts +++ b/apps/api/src/routers/invite.ts @@ -58,7 +58,8 @@ const bookSlot = factory.createHandlers(async (c) => { streamServerClient, transporter, data.user_details.email, - ownerData.email + ownerData.email, + { isTemporary: true } ); if (!result.success) { diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 90b9f52..f466c85 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -33,7 +33,11 @@ const upsertStreamUserFromProfile = async ( streamServerClient: AuthEnv["Variables"]["streamServerClient"], userId: string ) => { - const { data: profile } = await supabase.from("profiles").select("name").eq("id", userId).maybeSingle(); + const { data: profile } = await supabase + .from("profiles") + .select("name") + .eq("id", userId) + .maybeSingle(); await streamServerClient.upsertUser({ id: userId, @@ -255,10 +259,7 @@ const deleteTablo = factory.createHandlers(async (c) => { } } - const { error } = await supabase - .from("tablos") - .update({ deleted_at: deletedAt }) - .eq("id", id); + const { error } = await supabase.from("tablos").update({ deleted_at: deletedAt }).eq("id", id); if (error) { return c.json({ error: error.message }, 500); @@ -352,7 +353,8 @@ const inviteToTablo = ( streamServerClient, transporter, recipientEmail, - sender.email + sender.email, + { isTemporary: true } ); if (!result.success) { @@ -427,9 +429,7 @@ ${introEmail ? `

    ${introEmail}

    ` : ""} }); }); -const cancelPendingInvite = ( - middlewareManager: ReturnType -) => +const cancelPendingInvite = (middlewareManager: ReturnType) => factory.createHandlers(middlewareManager.regularUserCheck, async (c) => { const user = c.get("user"); const supabase = c.get("supabase"); diff --git a/apps/api/src/routers/user.ts b/apps/api/src/routers/user.ts index 34f1d2e..0d8d0a0 100644 --- a/apps/api/src/routers/user.ts +++ b/apps/api/src/routers/user.ts @@ -35,7 +35,10 @@ const getMe = factory.createHandlers(async (c) => { const { data, error } = await supabase.from("profiles").select("*").eq("id", user.id).single(); - const userData = data as Tables<"profiles">; + const userData = data as Tables<"profiles"> & { + organization_id: number | null; + plan: string | null; + }; if (!userData) { return c.json({ error: "User not found" }, 404); @@ -45,11 +48,21 @@ const getMe = factory.createHandlers(async (c) => { return c.json({ error: error.message }, 500); } + let effectivePlan: string | null = userData.plan; + if (userData.organization_id) { + const { plan: organizationPlan } = await getOrganizationPlan( + supabase, + userData.organization_id + ); + effectivePlan = organizationPlan; + } + const user_id = data.id; const token = streamServerClient.createToken(user_id); return c.json({ ...userData, + plan: effectivePlan, streamToken: token, }); }); @@ -313,6 +326,11 @@ const getOrganization = factory.createHandlers(async (c) => { return c.json({ error: "Failed to resolve organization plan" }, 500); } + const membersWithEffectivePlan = (members || []).map((member) => ({ + ...member, + plan, + })); + const { data: billingState, error: billingError } = await getOrganizationBillingState( supabase, organizationId @@ -329,7 +347,7 @@ const getOrganization = factory.createHandlers(async (c) => { member_count: members?.length || 0, tablo_count: tabloCount || 0, }, - members: members || [], + members: membersWithEffectivePlan, trial_starts_at: billingState.trial_starts_at, trial_ends_at: billingState.trial_ends_at, is_trial_expired: billingState.is_trial_expired, @@ -469,7 +487,8 @@ const inviteToOrganization = factory.createHandlers(async (c) => { streamServerClient, transporter, recipientEmail, - senderProfile.email + senderProfile.email, + { isTemporary: false } ); if (!invitedUser.success || !invitedUser.userId) { @@ -490,7 +509,7 @@ const inviteToOrganization = factory.createHandlers(async (c) => { const { error: assignOrganizationError } = await supabase .from("profiles") - .update({ organization_id: organizationId }) + .update({ organization_id: organizationId, is_temporary: false }) .eq("id", invitedUser.userId); if (assignOrganizationError) { From 992b846a8590702a68bd9b1683200ec3e46235e2 Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Sun, 8 Mar 2026 21:28:44 +0100 Subject: [PATCH 3/7] Fix CircleCI docker node pull --- .circleci/config.yml | 76 +- .../src/__tests__/routes/tablo_data.test.ts | 13 +- apps/api/src/routers/tablo_data.ts | 4 +- apps/main/src/components/ActionCard.tsx | 10 +- .../main/src/components/DashboardTaskList.tsx | 39 +- apps/main/src/components/ExceptionModal.tsx | 2 +- apps/main/src/components/ProjectCard.tsx | 32 +- .../src/components/TabloEventsSection.tsx | 14 +- .../main/src/components/TabloFilesSection.tsx | 6 +- .../src/components/TabloHeaderActions.tsx | 7 +- .../src/components/TabloMembersSection.tsx | 12 +- .../main/src/components/TabloTasksSection.tsx | 2 +- apps/main/src/components/TopBar.tsx | 41 +- apps/main/src/components/gantt/GanttChart.tsx | 79 +- apps/main/src/components/kanban/TaskModal.tsx | 32 +- apps/main/src/hooks/tasks.ts | 5 +- apps/main/src/lib/routes.tsx | 9 +- apps/main/src/lib/rum.ts | 1 + apps/main/src/main.css | 1804 ++++++++--------- apps/main/src/pages/availabilities.tsx | 34 +- apps/main/src/pages/chat.tsx | 82 +- apps/main/src/pages/events.tsx | 516 +++-- apps/main/src/pages/files.tsx | 69 +- apps/main/src/pages/login-v2.tsx | 59 +- apps/main/src/pages/login.tsx | 46 +- apps/main/src/pages/planning.tsx | 226 +-- apps/main/src/pages/tablo-details.tsx | 568 +++--- apps/main/src/pages/tablo.tsx | 169 +- apps/main/src/pages/tablos.tsx | 106 +- apps/main/src/pages/tasks.tsx | 164 +- apps/main/src/utils/etapeProgress.ts | 4 +- apps/main/stats.html | 2 +- packages/shared-types/src/index.ts | 8 +- packages/shared-types/src/tablo-data.types.ts | 1 - 34 files changed, 1874 insertions(+), 2368 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index ea82ebf..c40679e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -14,7 +14,7 @@ jobs: executor: name: node/default resource_class: small - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -22,15 +22,12 @@ jobs: - run: name: Run linting command: pnpm run lint - - run: - name: Check formatting - command: pnpm run format --check || echo "Format check complete" test-typecheck: executor: name: node/default resource_class: small - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -44,7 +41,7 @@ jobs: executor: name: node/default resource_class: medium - tag: '16' + tag: 'lts' steps: - checkout - node/install-packages: @@ -52,44 +49,27 @@ jobs: cache-path: ~/.pnpm-store - run: name: Run unit tests - command: pnpm run test - - store_test_results: - path: apps/main/coverage - - store_artifacts: - path: apps/main/coverage - destination: coverage + command: pnpm --filter @xtablo/main run test test-api: executor: name: node/default - tag: '16' + tag: 'lts' resource_class: small steps: - checkout - - restore_cache: - name: Restore npm API Cache - keys: - - npm-api-{{ checksum "api/package-lock.json" }} + - node/install-packages: + pkg-manager: pnpm + cache-path: ~/.pnpm-store - run: - name: Install API dependencies + name: Run API checks command: | - cd api - npm ci - - save_cache: - name: Save npm API Cache - key: npm-api-{{ checksum "api/package-lock.json" }} - paths: - - api/node_modules - - run: - name: Lint API - command: | - cd api - npm run lint - - run: - name: Run API tests - command: | - cd api - npm run test + if [ "${RUN_API_INTEGRATION_TESTS:-0}" = "1" ]; then + pnpm --filter @xtablo/api run test + else + echo "Skipping API integration tests (set RUN_API_INTEGRATION_TESTS=1 to enable)." + pnpm --filter @xtablo/api run build + fi # ============================================ # BUILD PHASE @@ -123,8 +103,6 @@ jobs: paths: - apps/main/dist - apps/external/dist - - packages/ui/dist - - packages/shared/dist - store_artifacts: path: apps/main/dist destination: main-app-<< parameters.environment >> @@ -138,26 +116,18 @@ jobs: resource_class: small steps: - checkout - - restore_cache: - name: Restore npm API Cache - keys: - - npm-api-{{ checksum "api/package-lock.json" }} - - run: - name: Install API dependencies - command: | - cd api - npm ci + - node/install-packages: + pkg-manager: pnpm + cache-path: ~/.pnpm-store - run: name: Build API - command: | - cd api - npm run build + command: pnpm --filter @xtablo/api run build - persist_to_workspace: root: . paths: - - api/dist + - apps/api/dist - store_artifacts: - path: api/dist + path: apps/api/dist destination: api # ============================================ @@ -174,9 +144,7 @@ jobs: at: . - run: name: Build API Docker image - command: | - cd api - docker build -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest . + command: docker build -f apps/api/Dockerfile -t xtablo-api:${CIRCLE_SHA1} -t xtablo-api:latest . - run: name: Save Docker image command: | diff --git a/apps/api/src/__tests__/routes/tablo_data.test.ts b/apps/api/src/__tests__/routes/tablo_data.test.ts index 780755d..b7033c5 100644 --- a/apps/api/src/__tests__/routes/tablo_data.test.ts +++ b/apps/api/src/__tests__/routes/tablo_data.test.ts @@ -6,8 +6,8 @@ import { PutObjectCommand, S3Client, } from "@aws-sdk/client-s3"; -import type { TabloFoldersMetadata } from "@xtablo/shared-types"; import { sdkStreamMixin } from "@smithy/util-stream"; +import type { TabloFoldersMetadata } from "@xtablo/shared-types"; import { mockClient } from "aws-sdk-client-mock"; import { testClient } from "hono/testing"; import { Readable } from "stream"; @@ -664,12 +664,7 @@ describe("TabloData Endpoint", () => { }); it("should return 400 if folder name is empty string", async () => { - const res = await createFolderRequest( - ownerUser, - client, - "test_tablo_owner_private", - " " - ); + const res = await createFolderRequest(ownerUser, client, "test_tablo_owner_private", " "); expect(res.status).toBe(400); const data = await res.json(); @@ -1110,9 +1105,7 @@ describe("TabloData Endpoint", () => { }); }); - describe( - "DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", - () => { + describe("DELETE /tablo-data/:tabloId/file/:path - Delete File with Nested Path (Admin or Uploader)", () => { it("should allow admin to delete file with nested path", async () => { const res = await deleteNestedFileRequest( ownerUser, diff --git a/apps/api/src/routers/tablo_data.ts b/apps/api/src/routers/tablo_data.ts index a356010..37c75b8 100644 --- a/apps/api/src/routers/tablo_data.ts +++ b/apps/api/src/routers/tablo_data.ts @@ -230,9 +230,7 @@ const deleteTabloFile = (middlewareManager: ReturnType
    {icon} @@ -65,7 +65,7 @@ export function ActionCard({ {label} @@ -79,7 +79,7 @@ export function ActionCard({

    {description} diff --git a/apps/main/src/components/DashboardTaskList.tsx b/apps/main/src/components/DashboardTaskList.tsx index c3f71c1..05cee3f 100644 --- a/apps/main/src/components/DashboardTaskList.tsx +++ b/apps/main/src/components/DashboardTaskList.tsx @@ -13,28 +13,21 @@ type TaskWithTablo = KanbanTask & { tablos: { id: string; name: string; color: string | null } | null; }; -const STATUS_BADGE: Record< - TaskStatus, - { className: string; labelKey: string } -> = { +const STATUS_BADGE: Record = { todo: { - className: - "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400", + className: "bg-blue-50 text-blue-600 dark:bg-blue-950/30 dark:text-blue-400", labelKey: "dashboard.taskList.status.todo", }, in_progress: { - className: - "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400", + className: "bg-yellow-50 text-yellow-600 dark:bg-yellow-950/30 dark:text-yellow-400", labelKey: "dashboard.taskList.status.inProgress", }, in_review: { - className: - "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400", + className: "bg-purple-50 text-purple-600 dark:bg-purple-950/30 dark:text-purple-400", labelKey: "dashboard.taskList.status.inReview", }, done: { - className: - "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400", + className: "bg-green-50 text-green-600 dark:bg-green-950/30 dark:text-green-400", labelKey: "dashboard.taskList.status.done", }, }; @@ -76,7 +69,7 @@ function TaskRow({ "w-6 h-6 rounded-full border-2 flex items-center justify-center shrink-0", isDone ? "bg-purple-600 border-purple-600" - : "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500", + : "border-gray-300 hover:border-purple-400 dark:border-gray-600 dark:hover:border-purple-500" )} onClick={(e) => { e.stopPropagation(); @@ -92,7 +85,7 @@ function TaskRow({ "text-sm font-medium truncate", isDone ? "line-through text-gray-400 dark:text-gray-500" - : "text-gray-900 dark:text-gray-100", + : "text-gray-900 dark:text-gray-100" )} > {task.title} @@ -105,7 +98,7 @@ function TaskRow({

    @@ -128,7 +121,7 @@ function TaskRow({ {t(badge.labelKey)} @@ -146,14 +139,10 @@ export function DashboardTaskList() { const [isTaskModalOpen, setIsTaskModalOpen] = useState(false); // Filter to tasks assigned to the current user, limited to recent ones - const myTasks = - allTasks - ?.filter((task) => task.assignee_id === user.id) - .slice(0, 7) ?? []; + const myTasks = allTasks?.filter((task) => task.assignee_id === user.id).slice(0, 7) ?? []; const handleToggleDone = (task: TaskWithTablo) => { - const newStatus: TaskStatus = - task.status === "done" ? "todo" : "done"; + const newStatus: TaskStatus = task.status === "done" ? "todo" : "done"; updateTask.mutate({ id: task.id, status: newStatus }); }; @@ -177,11 +166,7 @@ export function DashboardTaskList() {
    {myTasks.map((task) => ( - + ))}
    diff --git a/apps/main/src/components/ExceptionModal.tsx b/apps/main/src/components/ExceptionModal.tsx index 65acae3..65aafd9 100644 --- a/apps/main/src/components/ExceptionModal.tsx +++ b/apps/main/src/components/ExceptionModal.tsx @@ -41,7 +41,7 @@ export const ExceptionModal = ({ }) => { const { t } = useTranslation("components"); const form = useForm>({ - // eslint-disable-next-line @typescript-eslint/no-explicit-any + // biome-ignore lint/suspicious/noExplicitAny: zodResolver typing is incompatible with current zod type in this package. resolver: zodResolver(formSchema as any), defaultValues: { exceptionType: "day", diff --git a/apps/main/src/components/ProjectCard.tsx b/apps/main/src/components/ProjectCard.tsx index 84c04cd..3720cc7 100644 --- a/apps/main/src/components/ProjectCard.tsx +++ b/apps/main/src/components/ProjectCard.tsx @@ -64,12 +64,7 @@ export interface ProjectCardProps { className?: string; } -export function ProjectCard({ - tablo, - onClick, - onMenuClick, - className, -}: ProjectCardProps) { +export function ProjectCard({ tablo, onClick, onMenuClick, className }: ProjectCardProps) { const { t } = useTranslation("pages"); const statusConfig = useStatusConfig(tablo.status); const progress = getProgressFromStatus(tablo.status); @@ -84,7 +79,7 @@ export function ProjectCard({
    onClick?.(tablo.id)} > @@ -93,7 +88,7 @@ export function ProjectCard({ {statusConfig.label} @@ -114,15 +109,11 @@ export function ProjectCard({
    {tablo.image ? ( - {tablo.name} + {tablo.name} ) : ( {tablo.name.charAt(0).toUpperCase()} @@ -143,19 +134,12 @@ export function ProjectCard({ {/* Progress */}
    - - {t("tablo.card.progress")}: - - - {progress}% - + {t("tablo.card.progress")}: + {progress}%
    diff --git a/apps/main/src/components/TabloEventsSection.tsx b/apps/main/src/components/TabloEventsSection.tsx index 1be30a9..998172e 100644 --- a/apps/main/src/components/TabloEventsSection.tsx +++ b/apps/main/src/components/TabloEventsSection.tsx @@ -1,12 +1,12 @@ import { UserTablo } from "@xtablo/shared/types/tablos.types"; import { Button } from "@xtablo/ui/components/button"; +import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; import { Calendar, Clock, Plus } from "lucide-react"; +import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { useEventsByTablo } from "../hooks/events"; import { useIsReadOnlyUser } from "../providers/UserStoreProvider"; import { TabloHeaderActions } from "./TabloHeaderActions"; -import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; -import { useTranslation } from "react-i18next"; interface TabloEventsSectionProps { tablo: UserTablo; @@ -65,7 +65,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) {t("tablo:events.description")} {!isReadOnly && ( - @@ -172,7 +175,10 @@ export const TabloEventsSection = ({ tablo, isAdmin }: TabloEventsSectionProps) Aucun événement à venir pour ce tablo

    {!isReadOnly && ( - diff --git a/apps/main/src/components/TabloFilesSection.tsx b/apps/main/src/components/TabloFilesSection.tsx index 14744f1..b05aff7 100644 --- a/apps/main/src/components/TabloFilesSection.tsx +++ b/apps/main/src/components/TabloFilesSection.tsx @@ -455,7 +455,11 @@ const FolderSection = ({ export const TabloFilesSection = ({ tablo, isAdmin }: TabloFilesSectionProps) => { const currentUser = useUser(); - const { data: fileData, isLoading: filesLoading, error: filesError } = useTabloFileNames(tablo.id); + const { + data: fileData, + isLoading: filesLoading, + error: filesError, + } = useTabloFileNames(tablo.id); const { data: foldersData, isLoading: foldersLoading, diff --git a/apps/main/src/components/TabloHeaderActions.tsx b/apps/main/src/components/TabloHeaderActions.tsx index 0160717..e4f1757 100644 --- a/apps/main/src/components/TabloHeaderActions.tsx +++ b/apps/main/src/components/TabloHeaderActions.tsx @@ -13,12 +13,12 @@ import { Input } from "@xtablo/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger } from "@xtablo/ui/components/popover"; import { Loader2, Settings, Share2, X } from "lucide-react"; import { useEffect, useRef, useState } from "react"; -import { ClickOutside } from "./ClickOutside"; -import { ImageColorPicker } from "./ImageColorPicker"; import { useInviteUser } from "../hooks/invite"; import { useCancelTabloInvite, usePendingTabloInvitesByTablo } from "../hooks/tablo_invites"; import { useTabloMembers, useUpdateTablo } from "../hooks/tablos"; import { useUser } from "../providers/UserStoreProvider"; +import { ClickOutside } from "./ClickOutside"; +import { ImageColorPicker } from "./ImageColorPicker"; interface TabloHeaderActionsProps { tablo: UserTablo; @@ -303,7 +303,8 @@ export const TabloHeaderActions = ({ tablo, isAdmin }: TabloHeaderActionsProps)
    {filteredMembers.map((member, index) => { const avatarUrl = - member.avatar_url ?? (member.id === currentUser.id ? currentUser.avatar_url : null); + member.avatar_url ?? + (member.id === currentUser.id ? currentUser.avatar_url : null); return (
    - cancelInvite({ tabloId: tablo.id, inviteId: invite.id }) - } + onClick={() => cancelInvite({ tabloId: tablo.id, inviteId: invite.id })} disabled={isCancellingInvite} > Retirer diff --git a/apps/main/src/components/TabloTasksSection.tsx b/apps/main/src/components/TabloTasksSection.tsx index 759b404..054b5f1 100644 --- a/apps/main/src/components/TabloTasksSection.tsx +++ b/apps/main/src/components/TabloTasksSection.tsx @@ -1,6 +1,7 @@ import { pluralize, toast } from "@xtablo/shared"; import { UserTablo } from "@xtablo/shared/types/tablos.types"; import type { KanbanColumn, KanbanTask, KanbanTaskInsert, TaskStatus } from "@xtablo/shared-types"; +import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; import { AlertTriangle, ListChecks } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useTabloMembers } from "../hooks/tablos"; @@ -13,7 +14,6 @@ import { import { KanbanBoard } from "./kanban/KanbanBoard"; import { TaskModal } from "./kanban/TaskModal"; import { TabloHeaderActions } from "./TabloHeaderActions"; -import { TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; interface TabloTasksSectionProps { tablo: UserTablo; diff --git a/apps/main/src/components/TopBar.tsx b/apps/main/src/components/TopBar.tsx index 80031aa..91b2fe9 100644 --- a/apps/main/src/components/TopBar.tsx +++ b/apps/main/src/components/TopBar.tsx @@ -1,4 +1,5 @@ import type { Database } from "@xtablo/shared-types"; +import { Avatar, AvatarFallback, AvatarImage } from "@xtablo/ui/components/avatar"; import { Badge } from "@xtablo/ui/components/badge"; import { Button } from "@xtablo/ui/components/button"; import { @@ -8,15 +9,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@xtablo/ui/components/dropdown-menu"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "@xtablo/ui/components/avatar"; -import { - TypographyMuted, - TypographySmall, -} from "@xtablo/ui/components/typography"; +import { TypographyMuted, TypographySmall } from "@xtablo/ui/components/typography"; import { BellIcon, CalendarCheckIcon, @@ -36,10 +29,7 @@ import { useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Link, useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { useLogout } from "../hooks/auth"; -import { - useNotifications, - useNotificationsSubscription, -} from "../hooks/notifications"; +import { useNotifications, useNotificationsSubscription } from "../hooks/notifications"; import { useAcceptTabloInvite, useCancelTabloInvite, @@ -171,8 +161,7 @@ function NotificationItem({ function NotificationDropdown() { const { t } = useTranslation("navigation"); - const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = - useNotifications(); + const { notifications, unreadCount, isLoading, markAsRead, markAllAsRead } = useNotifications(); const { setupSubscription } = useNotificationsSubscription(); useEffect(() => { @@ -209,9 +198,7 @@ function NotificationDropdown() { {t("notifications.title", "Notifications")} {unreadCount > 0 && ( - - {unreadCount} - + {unreadCount} )}
    {unreadCount > 0 && ( @@ -250,10 +237,7 @@ function NotificationDropdown() {
    {notifications.map((notification) => (
    - +
    ))}
    @@ -298,12 +282,16 @@ function TabloInvitesDropdown() { sideOffset={8} >
    - {t("invites.title")} + + {t("invites.title")} +
    {invites.map((invite) => (
    - {invite.tablo_name} + + {invite.tablo_name} +
    )} - - +
    - {/* Filters */} -
    -
    - {/* Search */} -
    -
    - - setSearchTerm(e.target.value)} - className="pl-10 h-10" - /> -
    + {/* Filters */} +
    +
    + {/* Search */} +
    +
    + + setSearchTerm(e.target.value)} + className="pl-10 h-10" + />
    - - {/* Tablo Filter */} -
    - -
    - - {/* Status Filter */} - - {statusOptions.map((option) => ( - - ))} -
    -
    - {/* Events List */} -
    - {tablosLoading || eventsLoading ? ( -
    - -
    - ) : paginatedEvents.length === 0 ? ( -
    - -

    - {t("pages:events.emptyState.title")} -

    -

    - {searchTerm || statusFilter !== "all" - ? t("pages:events.emptyState.noResults") - : t("pages:events.emptyState.noEvents")} -

    -
    - ) : ( -
    - {paginatedEvents.map((event) => ( -
    -
    -
    -
    - - {event.title || "Événement sans titre"} - - {getEventStatusBadge(event)} -
    - -
    - - - {formatEventDateTime(event)} - - {event.tablo_name && ( - - {event.tablo_name} - + {/* Tablo Filter */} +
    + +
    - {event.description && ( - - {event.description} - + {/* Status Filter */} + + {statusOptions.map((option) => ( + + ))} + +
    +
    + + {/* Events List */} +
    + {tablosLoading || eventsLoading ? ( +
    + +
    + ) : paginatedEvents.length === 0 ? ( +
    + +

    + {t("pages:events.emptyState.title")} +

    +

    + {searchTerm || statusFilter !== "all" + ? t("pages:events.emptyState.noResults") + : t("pages:events.emptyState.noEvents")} +

    +
    + ) : ( +
    + {paginatedEvents.map((event) => ( +
    +
    +
    +
    + + {event.title || "Événement sans titre"} + + {getEventStatusBadge(event)} +
    + +
    + + + {formatEventDateTime(event)} + + {event.tablo_name && ( + + {event.tablo_name} + )}
    -
    - -
    + {event.description && ( + + {event.description} + + )} +
    + +
    +
    - ))} -
    - )} -
    +
    + ))} +
    + )} +
    - {/* Pagination Controls */} - {totalItems > 0 && ( -
    -
    -
    - - {t("pages:events.pagination.showing", { - start: startIndex + 1, - end: Math.min(endIndex, totalItems), - total: totalItems, - })} + {/* Pagination Controls */} + {totalItems > 0 && ( +
    +
    +
    + + {t("pages:events.pagination.showing", { + start: startIndex + 1, + end: Math.min(endIndex, totalItems), + total: totalItems, + })} + +
    + + {t("pages:events.pagination.itemsPerPage")} -
    - - {t("pages:events.pagination.itemsPerPage")} - - setItemsPerPage(Number(value))} + > + - - - - - 5 - 10 - 20 - 50 - - -
    + + + + 5 + 10 + 20 + 50 + +
    - - {totalPages > 1 && ( -
    - - -
    - {Array.from({ length: totalPages }, (_, i) => i + 1) - .filter((page) => { - return ( - page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1 - ); - }) - .map((page, index, array) => { - const prevPage = array[index - 1]; - const showEllipsis = prevPage && page - prevPage > 1; - - return ( -
    - {showEllipsis && ( - ... - )} - -
    - ); - })} -
    - - -
    - )}
    -
    - )} - {/* Stats Summary */} - {filteredEvents.length > 0 && ( -
    -
    -
    -
    - {filteredEvents.length} -
    -
    - {t("pages:events.stats.found")} + {totalPages > 1 && ( +
    + + +
    + {Array.from({ length: totalPages }, (_, i) => i + 1) + .filter((page) => { + return ( + page === 1 || page === totalPages || Math.abs(page - currentPage) <= 1 + ); + }) + .map((page, index, array) => { + const prevPage = array[index - 1]; + const showEllipsis = prevPage && page - prevPage > 1; + + return ( +
    + {showEllipsis && ( + ... + )} + +
    + ); + })}
    + +
    -
    -
    - { - filteredEvents.filter((e) => { - if (!e.start_date) return false; - const eventDate = new Date(e.start_date); - return eventDate >= new Date(); - }).length - } -
    -
    - {t("pages:events.stats.upcoming")} -
    + )} +
    +
    + )} + + {/* Stats Summary */} + {filteredEvents.length > 0 && ( +
    +
    +
    +
    {filteredEvents.length}
    +
    + {t("pages:events.stats.found")}
    -
    -
    - { - filteredEvents.filter((e) => { - if (!e.start_date) return false; - const today = new Date(); - today.setHours(0, 0, 0, 0); - const eventDate = new Date(e.start_date); - eventDate.setHours(0, 0, 0, 0); - return eventDate.getTime() === today.getTime(); - }).length - } -
    -
    - {t("pages:events.stats.today")} -
    +
    +
    +
    + { + filteredEvents.filter((e) => { + if (!e.start_date) return false; + const eventDate = new Date(e.start_date); + return eventDate >= new Date(); + }).length + } +
    +
    + {t("pages:events.stats.upcoming")} +
    +
    +
    +
    + { + filteredEvents.filter((e) => { + if (!e.start_date) return false; + const today = new Date(); + today.setHours(0, 0, 0, 0); + const eventDate = new Date(e.start_date); + eventDate.setHours(0, 0, 0, 0); + return eventDate.getTime() === today.getTime(); + }).length + } +
    +
    + {t("pages:events.stats.today")}
    - )} +
    + )}
    {/* Event Details Modal */} @@ -522,7 +511,6 @@ export function EventsPage() { onEdit={() => selectedEvent && handleEditEvent(selectedEvent)} canEdit={selectedEvent ? canEditEvent(selectedEvent) : false} /> - {/* Render child routes (e.g. EventModal) */} diff --git a/apps/main/src/pages/files.tsx b/apps/main/src/pages/files.tsx index 1d8b455..f99f2b8 100644 --- a/apps/main/src/pages/files.tsx +++ b/apps/main/src/pages/files.tsx @@ -1,12 +1,7 @@ -import { toast } from "@xtablo/shared"; import { LoadingSpinner } from "@ui/components/LoadingSpinner"; +import { toast } from "@xtablo/shared"; import { Button } from "@xtablo/ui/components/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@xtablo/ui/components/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@xtablo/ui/components/dialog"; import { DropdownMenu, DropdownMenuContent, @@ -25,18 +20,18 @@ import { import { useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Link, useSearchParams } from "react-router-dom"; -import { - extractFolderIdFromFileName, - getFileNameWithoutFolder, - getFolderFilePrefix, - useTabloFolders, -} from "../hooks/tablo_folders"; import { useAllTablosFileNames, useCreateTabloFile, useDeleteTabloFile, useDownloadTabloFile, } from "../hooks/tablo_data"; +import { + extractFolderIdFromFileName, + getFileNameWithoutFolder, + getFolderFilePrefix, + useTabloFolders, +} from "../hooks/tablo_folders"; import { useTablosList } from "../hooks/tablos"; // Derive icon color from file extension @@ -146,7 +141,9 @@ function UploadModal({ > {tablo.name.charAt(0).toUpperCase()} - {tablo.name} + + {tablo.name} + ))}
    @@ -155,7 +152,9 @@ function UploadModal({ {/* Folder selector (optional) */} {folders.length > 0 && (
    - +