diff --git a/.superpowers/brainstorm/12949-1773480945/.server.pid b/.superpowers/brainstorm/12949-1773480945/.server.pid new file mode 100644 index 0000000..6aa9699 --- /dev/null +++ b/.superpowers/brainstorm/12949-1773480945/.server.pid @@ -0,0 +1 @@ +12949 diff --git a/.superpowers/brainstorm/14566-1773480981/.server-stopped b/.superpowers/brainstorm/14566-1773480981/.server-stopped new file mode 100644 index 0000000..07b2c75 --- /dev/null +++ b/.superpowers/brainstorm/14566-1773480981/.server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1773483820241} diff --git a/.superpowers/brainstorm/14566-1773480981/.server.pid b/.superpowers/brainstorm/14566-1773480981/.server.pid new file mode 100644 index 0000000..ac252f1 --- /dev/null +++ b/.superpowers/brainstorm/14566-1773480981/.server.pid @@ -0,0 +1 @@ +14566 diff --git a/.superpowers/brainstorm/14566-1773480981/overview-builder-v1.html b/.superpowers/brainstorm/14566-1773480981/overview-builder-v1.html new file mode 100644 index 0000000..d4c1c94 --- /dev/null +++ b/.superpowers/brainstorm/14566-1773480981/overview-builder-v1.html @@ -0,0 +1,56 @@ +

Tablo Details — No-Code Overview Builder (V1)

+

+ Proposed interaction model: same page, toggle edit mode, drag blocks inside fixed zones. +

+ +
+
+
+
A
+
+

View/Edit Toggle + Auto-Save

+

+ "Edit layout" appears for allowed users. Drag-and-drop saves instantly after each drop. + "Reset default" restores the initial layout. +

+
+
+
+
+ +
+
+
Normal view (for all members)
+
+
Header + metadata + tabs
+
+
Left Zone: Description
+
Right Zone: Files
+
Left Zone: Mes taches
+
Right Zone: Informations
+
+
+
+ +
+
Edit mode (admins + org members)
+
+
Header + metadata + tabs + "Done editing"
+
+
+ Left Zone +
[::] Description
+
[::] Mes taches
+
+
+ Right Zone +
[::] Fichiers
+
[::] Informations
+
+
+
+ +
+
+
+
diff --git a/.superpowers/brainstorm/14566-1773480981/waiting-1.html b/.superpowers/brainstorm/14566-1773480981/waiting-1.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/14566-1773480981/waiting-1.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/apps/main/src/App.tsx b/apps/main/src/App.tsx index c2d2ebd..94ae254 100644 --- a/apps/main/src/App.tsx +++ b/apps/main/src/App.tsx @@ -3,7 +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 { PendingSignupCheckout } from "./components/PendingSignupCheckout"; import { TrialUpsellModal } from "./components/TrialUpsellModal"; import { UpgradePanel } from "./components/UpgradePanel"; import { UpgradeBlockProvider } from "./contexts/UpgradeBlockContext"; @@ -30,7 +30,7 @@ const Routes = () => { return ( - + {appElement} diff --git a/apps/main/src/components/PendingSignupCheckout.test.tsx b/apps/main/src/components/PendingSignupCheckout.test.tsx new file mode 100644 index 0000000..875fd84 --- /dev/null +++ b/apps/main/src/components/PendingSignupCheckout.test.tsx @@ -0,0 +1,118 @@ +import { render, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { PendingSignupCheckout } from "./PendingSignupCheckout"; + +const mockCreateCheckout = vi.fn(); + +vi.mock("../hooks/organization", () => ({ + useOrganization: vi.fn(), +})); + +vi.mock("../hooks/stripe", () => ({ + useCreateCheckoutSession: () => ({ + mutate: mockCreateCheckout, + }), +})); + +vi.mock("../providers/UserStoreProvider", () => ({ + useMaybeUser: vi.fn(), +})); + +describe("PendingSignupCheckout", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + it("creates checkout for pending solo", async () => { + const { useOrganization } = await import("../hooks/organization"); + const { useMaybeUser } = await import("../providers/UserStoreProvider"); + + vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never); + vi.mocked(useOrganization).mockReturnValue({ + isLoading: false, + data: { + active_subscription_plan: null, + is_billing_owner: true, + }, + } as never); + + localStorage.setItem("pendingBillingCheckoutPlan", "solo"); + render(); + + await waitFor(() => { + expect(mockCreateCheckout).toHaveBeenCalledWith( + expect.objectContaining({ plan: "solo" }), + expect.any(Object) + ); + }); + }); + + it("creates checkout for pending team", async () => { + const { useOrganization } = await import("../hooks/organization"); + const { useMaybeUser } = await import("../providers/UserStoreProvider"); + + vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never); + vi.mocked(useOrganization).mockReturnValue({ + isLoading: false, + data: { + active_subscription_plan: null, + is_billing_owner: true, + }, + } as never); + + localStorage.setItem("pendingBillingCheckoutPlan", "team"); + render(); + + await waitFor(() => { + expect(mockCreateCheckout).toHaveBeenCalledWith( + expect.objectContaining({ plan: "team" }), + expect.any(Object) + ); + }); + }); + + it("clears founder pending checkout when annual plan is already active", async () => { + const { useOrganization } = await import("../hooks/organization"); + const { useMaybeUser } = await import("../providers/UserStoreProvider"); + + vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never); + vi.mocked(useOrganization).mockReturnValue({ + isLoading: false, + data: { + active_subscription_plan: "annual", + is_billing_owner: true, + }, + } as never); + + localStorage.setItem("pendingBillingCheckoutPlan", "founder"); + render(); + + await waitFor(() => { + expect(localStorage.getItem("pendingBillingCheckoutPlan")).toBeNull(); + expect(mockCreateCheckout).not.toHaveBeenCalled(); + }); + }); + + it("clears invalid pending checkout value", async () => { + const { useOrganization } = await import("../hooks/organization"); + const { useMaybeUser } = await import("../providers/UserStoreProvider"); + + vi.mocked(useMaybeUser).mockReturnValue({ id: "user-1" } as never); + vi.mocked(useOrganization).mockReturnValue({ + isLoading: false, + data: { + active_subscription_plan: null, + is_billing_owner: true, + }, + } as never); + + localStorage.setItem("pendingBillingCheckoutPlan", "invalid"); + render(); + + await waitFor(() => { + expect(localStorage.getItem("pendingBillingCheckoutPlan")).toBeNull(); + expect(mockCreateCheckout).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/main/src/components/PendingFounderCheckout.tsx b/apps/main/src/components/PendingSignupCheckout.tsx similarity index 67% rename from apps/main/src/components/PendingFounderCheckout.tsx rename to apps/main/src/components/PendingSignupCheckout.tsx index e607f74..818f091 100644 --- a/apps/main/src/components/PendingFounderCheckout.tsx +++ b/apps/main/src/components/PendingSignupCheckout.tsx @@ -1,10 +1,14 @@ 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 { + PENDING_BILLING_CHECKOUT_PLAN_KEY, + isSignupBillingIntent, + satisfiesPendingCheckoutPlan, +} from "../lib/billing"; import { useMaybeUser } from "../providers/UserStoreProvider"; -export function PendingFounderCheckout() { +export function PendingSignupCheckout() { const user = useMaybeUser(); const { data: organizationData, isLoading } = useOrganization(); const { mutate: createCheckout } = useCreateCheckoutSession(); @@ -14,12 +18,17 @@ export function PendingFounderCheckout() { if (hasTriggered.current) return; if (!user || isLoading || !organizationData) return; - const pendingCheckout = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); - if (pendingCheckout !== "founder") { + const pendingCheckoutValue = localStorage.getItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + if (!isSignupBillingIntent(pendingCheckoutValue)) { + if (pendingCheckoutValue) { + localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); + } return; } - if (organizationData.active_subscription_plan === "annual") { + if ( + satisfiesPendingCheckoutPlan(pendingCheckoutValue, organizationData.active_subscription_plan) + ) { localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); return; } @@ -32,13 +41,13 @@ export function PendingFounderCheckout() { localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); createCheckout( { - plan: "founder", + plan: pendingCheckoutValue, successUrl: `${window.location.origin}/settings?success=true`, cancelUrl: `${window.location.origin}/settings?canceled=true`, }, { onError: () => { - localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, "founder"); + localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, pendingCheckoutValue); hasTriggered.current = false; }, } diff --git a/apps/main/src/hooks/auth.signup.test.ts b/apps/main/src/hooks/auth.signup.test.ts new file mode 100644 index 0000000..e393303 --- /dev/null +++ b/apps/main/src/hooks/auth.signup.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { resolveSignupBillingIntent } from "./auth"; + +describe("resolveSignupBillingIntent", () => { + it("defaults to solo when billing intent is missing", () => { + expect(resolveSignupBillingIntent()).toBe("solo"); + }); + + it("returns team when selected", () => { + expect(resolveSignupBillingIntent("team")).toBe("team"); + }); + + it("returns founder when selected", () => { + expect(resolveSignupBillingIntent("founder")).toBe("founder"); + }); +}); diff --git a/apps/main/src/hooks/auth.ts b/apps/main/src/hooks/auth.ts index 61371af..604d040 100644 --- a/apps/main/src/hooks/auth.ts +++ b/apps/main/src/hooks/auth.ts @@ -8,6 +8,7 @@ import { useNavigate } from "react-router-dom"; import { match } from "ts-pattern"; import { api } from "../lib/api"; import { + DEFAULT_SIGNUP_BILLING_INTENT, PENDING_BILLING_CHECKOUT_PLAN_KEY, SIGNUP_BILLING_INTENT_KEY, SignupBillingIntent, @@ -44,6 +45,10 @@ interface AuthResponse { session: Session | null; } +export const resolveSignupBillingIntent = ( + billingIntent?: SignupBillingIntent +): SignupBillingIntent => billingIntent ?? DEFAULT_SIGNUP_BILLING_INTENT; + export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { const navigate = useNavigate(); const [errors, setErrors] = useState>({}); @@ -63,7 +68,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", + billing_intent: resolveSignupBillingIntent(data.billing_intent), }, }, }); @@ -76,15 +81,9 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { return response; }, 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); - } + const selectedPlan = resolveSignupBillingIntent(variables.billing_intent); + localStorage.setItem(SIGNUP_BILLING_INTENT_KEY, selectedPlan); + localStorage.setItem(PENDING_BILLING_CHECKOUT_PLAN_KEY, selectedPlan); // If there's no session, it means email confirmation is required if (!data.user?.email_confirmed_at) { @@ -94,12 +93,12 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { return; } - if (wantsFounderCheckout && data.session?.access_token) { + if (data.session?.access_token) { try { const checkoutResponse = await api.post( "/api/v1/stripe/create-checkout-session", { - plan: "founder", + plan: selectedPlan, successUrl: `${window.location.origin}/settings?success=true`, cancelUrl: `${window.location.origin}/settings?canceled=true`, }, @@ -117,9 +116,9 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { return; } } catch (error) { - console.error("Founder checkout bootstrap failed after signup:", error); + console.error("Signup checkout bootstrap failed after signup:", error); toast.add({ - title: "Paiement Founder", + title: "Paiement", description: "Votre compte est créé, mais la redirection vers le paiement a échoué. Vous pourrez finaliser le paiement dans l'application.", type: "warning", @@ -129,9 +128,6 @@ export function useSignUp({ redirectUrl }: { redirectUrl: string | null }) { } localStorage.removeItem(SIGNUP_BILLING_INTENT_KEY); - if (!wantsFounderCheckout) { - localStorage.removeItem(PENDING_BILLING_CHECKOUT_PLAN_KEY); - } if (redirectUrl) { localStorage.removeItem("redirectUrl"); diff --git a/apps/main/src/lib/billing.test.ts b/apps/main/src/lib/billing.test.ts new file mode 100644 index 0000000..1454d21 --- /dev/null +++ b/apps/main/src/lib/billing.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_SIGNUP_BILLING_INTENT, + isSignupBillingIntent, + satisfiesPendingCheckoutPlan, +} from "./billing"; + +describe("billing helpers", () => { + it("defaults signup intent to solo", () => { + expect(DEFAULT_SIGNUP_BILLING_INTENT).toBe("solo"); + }); + + it("recognizes only solo, team and founder as signup intents", () => { + expect(isSignupBillingIntent("solo")).toBe(true); + expect(isSignupBillingIntent("team")).toBe(true); + expect(isSignupBillingIntent("founder")).toBe(true); + expect(isSignupBillingIntent("trial")).toBe(false); + }); + + it("computes checkout satisfaction against active subscription plan", () => { + expect(satisfiesPendingCheckoutPlan("founder", "annual")).toBe(true); + expect(satisfiesPendingCheckoutPlan("founder", "team")).toBe(false); + expect(satisfiesPendingCheckoutPlan("team", "team")).toBe(true); + expect(satisfiesPendingCheckoutPlan("team", "annual")).toBe(true); + expect(satisfiesPendingCheckoutPlan("solo", "solo")).toBe(true); + expect(satisfiesPendingCheckoutPlan("solo", "team")).toBe(true); + expect(satisfiesPendingCheckoutPlan("solo", "annual")).toBe(true); + expect(satisfiesPendingCheckoutPlan("solo", null)).toBe(false); + }); +}); diff --git a/apps/main/src/lib/billing.ts b/apps/main/src/lib/billing.ts index 2073fde..356921f 100644 --- a/apps/main/src/lib/billing.ts +++ b/apps/main/src/lib/billing.ts @@ -1,4 +1,28 @@ -export type SignupBillingIntent = "trial" | "founder"; +export type SignupBillingIntent = "solo" | "team" | "founder"; + +export const DEFAULT_SIGNUP_BILLING_INTENT: SignupBillingIntent = "solo"; export const SIGNUP_BILLING_INTENT_KEY = "signupBillingIntent"; export const PENDING_BILLING_CHECKOUT_PLAN_KEY = "pendingBillingCheckoutPlan"; + +export const isSignupBillingIntent = (value: string | null): value is SignupBillingIntent => + value === "solo" || value === "team" || value === "founder"; + +export const satisfiesPendingCheckoutPlan = ( + pendingPlan: SignupBillingIntent, + activePlan: "solo" | "team" | "annual" | null +): boolean => { + if (!activePlan) { + return false; + } + + if (pendingPlan === "founder") { + return activePlan === "annual"; + } + + if (pendingPlan === "team") { + return activePlan === "team" || activePlan === "annual"; + } + + return activePlan === "solo" || activePlan === "team" || activePlan === "annual"; +}; diff --git a/apps/main/src/pages/signup-v2.test.tsx b/apps/main/src/pages/signup-v2.test.tsx new file mode 100644 index 0000000..efa9734 --- /dev/null +++ b/apps/main/src/pages/signup-v2.test.tsx @@ -0,0 +1,81 @@ +import { fireEvent, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderWithProviders } from "../utils/testHelpers"; +import { SignUpV2Page } from "./signup-v2"; + +const mockSignUp = vi.fn(); + +vi.mock("../hooks/auth", () => ({ + useSignUp: () => ({ + mutate: mockSignUp, + isPending: false, + }), + useLoginEmail: () => ({ + mutate: vi.fn(), + isPending: false, + }), + useLoginGoogle: () => ({ + mutate: vi.fn(), + }), +})); + +describe("SignUpV2Page", () => { + beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + }); + + const fillAndSubmitForm = () => { + 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(screen.getByRole("checkbox", { name: /i accept/i })); + fireEvent.click(screen.getByRole("button", { name: /create my account/i })); + }; + + it("submits solo billing intent by default", async () => { + renderWithProviders(); + + fillAndSubmitForm(); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({ billing_intent: "solo" })); + }); + }); + + it("submits team billing intent when selected", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /team/i })); + fillAndSubmitForm(); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith(expect.objectContaining({ billing_intent: "team" })); + }); + }); + + it("submits founder billing intent when selected", async () => { + renderWithProviders(); + + fireEvent.click(screen.getByRole("button", { name: /founder/i })); + fillAndSubmitForm(); + + await waitFor(() => { + expect(mockSignUp).toHaveBeenCalledWith( + expect.objectContaining({ billing_intent: "founder" }) + ); + }); + }); +}); diff --git a/apps/main/src/pages/signup-v2.tsx b/apps/main/src/pages/signup-v2.tsx index 97bc18e..68abd6a 100644 --- a/apps/main/src/pages/signup-v2.tsx +++ b/apps/main/src/pages/signup-v2.tsx @@ -10,7 +10,7 @@ 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"; +import { DEFAULT_SIGNUP_BILLING_INTENT, type SignupBillingIntent } from "../lib/billing"; export function SignUpV2Page() { const { t } = useTranslation(["auth", "common"]); @@ -30,7 +30,9 @@ export function SignUpV2Page() { business_name: "", }); const [termsAccepted, setTermsAccepted] = useState(false); - const [billingIntent, setBillingIntent] = useState("trial"); + const [billingIntent, setBillingIntent] = useState( + DEFAULT_SIGNUP_BILLING_INTENT + ); const { theme, setTheme } = useTheme(); @@ -241,16 +243,30 @@ export function SignUpV2Page() {
+ +