From 65eda86b34c261a3bd1fc3fbc35bf9b3ab89e37a Mon Sep 17 00:00:00 2001 From: Arthur Belleville Date: Tue, 2 Dec 2025 21:42:35 +0100 Subject: [PATCH] Onboarding --- .../api/src/__tests__/helpers/helpers.test.ts | 8 +- apps/api/src/__tests__/routes/tablo.test.ts | 2 - apps/api/src/routers/tablo.ts | 2 +- apps/main/src/components/Layout.tsx | 2 +- .../src/components/OnboardingCelebration.tsx | 110 +++ apps/main/src/components/OnboardingModal.tsx | 816 ++++++++++++++---- apps/main/src/components/SubscriptionCard.tsx | 4 +- apps/main/src/components/TabloPointer.tsx | 104 +++ apps/main/src/components/UpgradePanel.tsx | 2 +- apps/main/src/locales/en/onboarding.json | 79 +- apps/main/src/locales/fr/onboarding.json | 79 +- apps/main/src/pages/tablo.tsx | 4 +- packages/ui/package.json | 5 +- packages/ui/src/components/index.ts | 1 + packages/ui/src/components/radio-group.tsx | 44 + packages/ui/src/components/tooltip.tsx | 3 +- pnpm-lock.yaml | 34 + .../20251202203228_fix_tablo_status.sql | 63 ++ .../database/12_compute_tablo_status.test.sql | 39 +- 19 files changed, 1103 insertions(+), 298 deletions(-) create mode 100644 apps/main/src/components/OnboardingCelebration.tsx create mode 100644 apps/main/src/components/TabloPointer.tsx create mode 100644 packages/ui/src/components/radio-group.tsx create mode 100644 supabase/migrations/20251202203228_fix_tablo_status.sql diff --git a/apps/api/src/__tests__/helpers/helpers.test.ts b/apps/api/src/__tests__/helpers/helpers.test.ts index d3fb848..e20836e 100644 --- a/apps/api/src/__tests__/helpers/helpers.test.ts +++ b/apps/api/src/__tests__/helpers/helpers.test.ts @@ -56,7 +56,7 @@ describe("verifyTabloLimitForUser", () => { beforeEach(() => { vi.clearAllMocks(); - next = vi.fn(async () => {}); + next = vi.fn(async () => Promise.resolve()); }); it("returns 500 when profile lookup fails", async () => { @@ -81,10 +81,7 @@ describe("verifyTabloLimitForUser", () => { await verifyTabloLimitForUser(ctx, next); - expect(ctx.json).toHaveBeenCalledWith( - { error: "You have reached your tablo limit" }, - 403 - ); + expect(ctx.json).toHaveBeenCalledWith({ error: "You have reached your tablo limit" }, 403); expect(next).not.toHaveBeenCalled(); }); @@ -114,4 +111,3 @@ describe("verifyTabloLimitForUser", () => { expect(supabase.tabloSelect).not.toHaveBeenCalled(); }); }); - diff --git a/apps/api/src/__tests__/routes/tablo.test.ts b/apps/api/src/__tests__/routes/tablo.test.ts index 20cf223..ca1288e 100644 --- a/apps/api/src/__tests__/routes/tablo.test.ts +++ b/apps/api/src/__tests__/routes/tablo.test.ts @@ -72,7 +72,6 @@ describe("Tablo Endpoint", () => { }); // Helper function to create tablo - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access const createTabloRequest = async ( user: TestUserData, // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access @@ -93,7 +92,6 @@ describe("Tablo Endpoint", () => { }; // Helper function to update tablo - // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access const updateTabloRequest = async ( user: TestUserData, // biome-ignore lint/suspicious/noExplicitAny: testClient requires any for dynamic route access diff --git a/apps/api/src/routers/tablo.ts b/apps/api/src/routers/tablo.ts index 913aa07..a888b17 100644 --- a/apps/api/src/routers/tablo.ts +++ b/apps/api/src/routers/tablo.ts @@ -60,7 +60,7 @@ const createTablo = (middlewareManager: ReturnType) => diff --git a/apps/main/src/components/Layout.tsx b/apps/main/src/components/Layout.tsx index c7cd8bf..48d63cb 100644 --- a/apps/main/src/components/Layout.tsx +++ b/apps/main/src/components/Layout.tsx @@ -16,7 +16,7 @@ export function Layout() { // Check if user has completed onboarding const hasCompletedOnboarding = localStorage.getItem(ONBOARDING_STORAGE_KEY); if (!hasCompletedOnboarding) { - setShowOnboarding(false); + setShowOnboarding(true); } }, []); diff --git a/apps/main/src/components/OnboardingCelebration.tsx b/apps/main/src/components/OnboardingCelebration.tsx new file mode 100644 index 0000000..a802977 --- /dev/null +++ b/apps/main/src/components/OnboardingCelebration.tsx @@ -0,0 +1,110 @@ +import { Sparkles } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface OnboardingCelebrationProps { + show: boolean; + onComplete: () => void; +} + +export function OnboardingCelebration({ show, onComplete }: OnboardingCelebrationProps) { + const [confetti, setConfetti] = useState< + Array<{ id: number; left: number; delay: number; duration: number }> + >([]); + + useEffect(() => { + if (show) { + // Generate confetti particles + const particles = Array.from({ length: 50 }, (_, i) => ({ + id: i, + left: Math.random() * 100, + delay: Math.random() * 0.5, + duration: 2 + Math.random() * 1, + })); + setConfetti(particles); + + // Clean up after animation + const timer = setTimeout(() => { + onComplete(); + }, 3500); + + return () => clearTimeout(timer); + } + }, [show, onComplete]); + + if (!show) return null; + + return ( +
+ {/* Confetti particles */} + {confetti.map((particle) => ( +
+ ))} + + {/* Success message overlay */} +
+
+
+
+ +
+
+

🎉 FĂ©licitations !

+

Votre Tablo a été créé avec succÚs

+
+
+
+
+ + +
+ ); +} diff --git a/apps/main/src/components/OnboardingModal.tsx b/apps/main/src/components/OnboardingModal.tsx index 9522a73..9c108d1 100644 --- a/apps/main/src/components/OnboardingModal.tsx +++ b/apps/main/src/components/OnboardingModal.tsx @@ -7,157 +7,119 @@ import { DialogHeader, DialogTitle, } from "@xtablo/ui/components/dialog"; -import { Calendar, CheckCircle, FolderKanban, MessageSquare, Sparkles } from "lucide-react"; -import { useMemo, useState } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import { Input } from "@xtablo/ui/components/input"; +import { Label } from "@xtablo/ui/components/label"; +import { RadioGroup, RadioGroupItem } from "@xtablo/ui/components/radio-group"; +import { + Briefcase, + Building2, + CheckCircle2, + FolderKanban, + Plus, + Sparkles, + Trash2, +} from "lucide-react"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { useCreateTablo } from "../hooks/tablos"; +import { useCreateEtape } from "../hooks/tasks"; +import { OnboardingCelebration } from "./OnboardingCelebration"; +import { TabloPointer } from "./TabloPointer"; interface OnboardingModalProps { open: boolean; onComplete: () => void; } -const STEP_ICONS = [Sparkles, FolderKanban, Calendar, MessageSquare, CheckCircle]; +type ProfileType = "freelance" | "agency" | null; -function StepContent({ stepKey }: { stepKey: string }) { - const { t } = useTranslation("onboarding"); - - if (stepKey === "welcome") { - return ( -
-

{t("modal.steps.welcome.content.intro")}

-
-

{t("modal.steps.welcome.content.features")}

-
-
- ); - } - - if (stepKey === "tablos") { - return ( -
-

- }} - /> -

-
    -
  • - - {t("modal.steps.tablos.content.feature1")} -
  • -
  • - - {t("modal.steps.tablos.content.feature2")} -
  • -
  • - - {t("modal.steps.tablos.content.feature3")} -
  • -
-
- ); - } - - if (stepKey === "planning") { - return ( -
-

{t("modal.steps.planning.content.intro")}

-
    -
  • - - {t("modal.steps.planning.content.feature1")} -
  • -
  • - - {t("modal.steps.planning.content.feature2")} -
  • -
  • - - {t("modal.steps.planning.content.feature3")} -
  • -
-
- ); - } - - if (stepKey === "chat") { - return ( -
-

{t("modal.steps.chat.content.intro")}

-
    -
  • - - {t("modal.steps.chat.content.feature1")} -
  • -
  • - - {t("modal.steps.chat.content.feature2")} -
  • -
  • - - {t("modal.steps.chat.content.feature3")} -
  • -
-
- ); - } - - if (stepKey === "ready") { - return ( -
-

{t("modal.steps.ready.content.intro")}

-
-
-

{t("modal.steps.ready.content.action1.title")}

-

- {t("modal.steps.ready.content.action1.description")} -

-
-
-

{t("modal.steps.ready.content.action2.title")}

-

- {t("modal.steps.ready.content.action2.description")} -

-
-
-

{t("modal.steps.ready.content.action3.title")}

-

- {t("modal.steps.ready.content.action3.description")} -

-
-
-
- ); - } - - return null; -} +const STEP_ICONS = [Sparkles, Briefcase, FolderKanban, Building2, CheckCircle2]; export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { const { t } = useTranslation("onboarding"); const [currentStep, setCurrentStep] = useState(0); + const [showCelebration, setShowCelebration] = useState(false); + const [showPointer, setShowPointer] = useState(false); + const [createdTabloId, setCreatedTabloId] = useState(null); - const STEP_KEYS = ["welcome", "tablos", "planning", "chat", "ready"]; + // Form state + const [profileType, setProfileType] = useState(null); + const [serviceName, setServiceName] = useState(""); + const [stepNames, setStepNames] = useState(["", "", ""]); + const [projectName, setProjectName] = useState(""); - const stepData = useMemo( - () => ({ - key: STEP_KEYS[currentStep], - title: t(`modal.steps.${STEP_KEYS[currentStep]}.title`), - description: t(`modal.steps.${STEP_KEYS[currentStep]}.description`), - icon: STEP_ICONS[currentStep], - }), - [currentStep, t] - ); + const createTablo = useCreateTablo(); + const createEtape = useCreateEtape(); - const Icon = stepData.icon; + const STEP_KEYS = ["welcome", "profile", "service", "structure", "project"]; + + const Icon = STEP_ICONS[currentStep]; const isLastStep = currentStep === STEP_KEYS.length - 1; const isFirstStep = currentStep === 0; - const handleNext = () => { + const canProceed = () => { + switch (currentStep) { + case 0: // Welcome + return true; + case 1: // Profile + return profileType !== null; + case 2: // Service + return serviceName.trim().length > 0; + case 3: // Structure + return stepNames.filter((name) => name.trim().length > 0).length >= 2; + case 4: // Project + return projectName.trim().length > 0; + default: + return false; + } + }; + + const handleNext = async () => { if (isLastStep) { - onComplete(); + // Create the tablo with the collected information + try { + const tabloData = { + name: projectName.trim(), + color: "bg-blue-500", + image: null, + status: "in_progress" as const, + }; + + const result = await createTablo.mutateAsync(tabloData); + + // Extract tablo ID from the result + // The API now returns { message: string, tablo: TabloData } + const tabloId = (result as { message: string; tablo?: { id: string } })?.tablo?.id; + + if (tabloId) { + // Create etapes for the tablo + const validSteps = stepNames.filter((name) => name.trim().length > 0); + + for (let i = 0; i < validSteps.length; i++) { + await createEtape.mutateAsync({ + tabloId, + title: validSteps[i].trim(), + position: i, + }); + } + + // Show celebration animation + setCreatedTabloId(tabloId); + setShowCelebration(true); + + // Navigate after celebration + onComplete(); + // Show pointer after navigation + setTimeout(() => { + setShowPointer(true); + }, 500); + } else { + onComplete(); + } + } catch { + // Still complete onboarding even if creation fails + onComplete(); + } } else { setCurrentStep((prev) => prev + 1); } @@ -171,57 +133,559 @@ export function OnboardingModal({ open, onComplete }: OnboardingModalProps) { onComplete(); }; - return ( - !open && handleSkip()}> - - -
-
- -
-
- {stepData.title} - {stepData.description} + const addStep = () => { + setStepNames([...stepNames, ""]); + }; + + const removeStep = (index: number) => { + if (stepNames.length > 2) { + setStepNames(stepNames.filter((_, i) => i !== index)); + } + }; + + const updateStepName = (index: number, value: string): void => { + const newSteps = [...stepNames]; + newSteps[index] = value; + setStepNames(newSteps); + }; + + const renderStepContent = () => { + const stepKey = STEP_KEYS[currentStep]; + + if (stepKey === "welcome") { + return ( +
+
+
+
+

+ {t("modal.steps.welcome.content.intro")} +

- - -
-
+ ); + } - {/* Progress indicators */} -
- {STEP_KEYS.map((_, index) => ( -
+
+ + setProfileType(value as ProfileType)} + className="space-y-3" + > + + + +
+
+ ); + } + + if (stepKey === "service") { + return ( +
+
+ + setServiceName(e.target.value)} + placeholder={t("modal.steps.service.placeholder")} + className="text-base h-12 px-4" + autoFocus /> - ))} +
+ ); + } - - -
- {!isFirstStep && ( - - )} - + )} +
+ ))} +
+
- - -
+
+ ); + } + + if (stepKey === "project") { + return ( +
+
+ + setProjectName(e.target.value)} + placeholder={t("modal.steps.project.placeholder")} + className="text-base h-12 px-4" + autoFocus + /> + {projectName && ( +
+

Récapitulatif :

+
+

+ Service : {serviceName} +

+

+ Étapes :{" "} + {stepNames.filter((s) => s.trim()).length} Ă©tapes configurĂ©es +

+
+
+ )} +
+
+ ); + } + + return null; + }; + + const renderPreview = () => { + const stepKey = STEP_KEYS[currentStep]; + + if (stepKey === "welcome") { + return ( +
+
+
+ +
+
+

XTablo

+

Votre espace de gestion projet

+
+
+ ); + } + + if (stepKey === "profile") { + return ( +
+
+ {/* Modern Illustration */} + {profileType === "freelance" ? ( + // Freelance illustration +
+
+
+
+
+
+ +
+
+
+

Freelance

+

+ Travail en solo +
+ Prestations personnalisées +

+
+
+
+
+
+
+
+
+
+
+ ) : profileType === "agency" ? ( + // Agency illustration +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+

Agence

+

+ Équipe collaborative +
+ Plusieurs projets simultanés +

+
+
+
+
+
+
+
+
+
+
+
+ ) : ( + // Placeholder +
+
+
+
+
+
+

+ Choisissez votre profil +

+

+ Sélectionnez votre type d'activité +
+ pour continuer +

+
+
+
+ )} +
+
+ ); + } + + if (stepKey === "service") { + return ( +
+
+
+
+ +
+
{serviceName || "Votre service"}
+
Service principal
+
+
+
+
+ +
+
+
+ +
+
+
+ +
+
+
+
+
+
+ ); + } + + if (stepKey === "structure") { + const validSteps = stepNames.filter((s) => s.trim()); + return ( +
+
+
+
+ +
Étapes
+
+
+ {validSteps.length > 0 ? ( + validSteps.map((step, index) => ( +
+
+ {index + 1} +
+
+ {step} +
+
+ )) + ) : ( + <> +
+
+
+
+
+
+
+
+ + )} +
+
+
+
+ ); + } + + if (stepKey === "project") { + return ( +
+
+
+
+ +
+
{projectName || "Nom du projet"}
+
{serviceName || "Service"}
+
+
+
+
+ Étapes + {stepNames.filter((s) => s.trim()).length} +
+
+ Type + {profileType || "—"} +
+
+
+
+
+ ); + } + + return null; + }; + + return ( + <> + setShowCelebration(false)} /> + setShowPointer(false)} + /> + !open && handleSkip()}> + +
+ {/* Left side - Form */} +
+ +
+
+ +
+
+ + {t(`modal.steps.${STEP_KEYS[currentStep]}.title`)} + + + {t(`modal.steps.${STEP_KEYS[currentStep]}.description`)} + +
+
+
+ +
{renderStepContent()}
+ + {/* Progress indicators */} +
+ {STEP_KEYS.map((_, index) => ( +
+ ))} +
+ + + +
+ {!isFirstStep && ( + + )} + +
+
+
+ + {/* Right side - Animated Preview */} +
+ {/* Decorative background elements */} +
+
+ + {/* Preview content */} +
{renderPreview()}
+
+
+ +
+ + + ); } diff --git a/apps/main/src/components/SubscriptionCard.tsx b/apps/main/src/components/SubscriptionCard.tsx index 698abef..e824628 100644 --- a/apps/main/src/components/SubscriptionCard.tsx +++ b/apps/main/src/components/SubscriptionCard.tsx @@ -1,3 +1,4 @@ +import { pluralize } from "@xtablo/shared"; import { Badge } from "@xtablo/ui/components/badge"; import { Button } from "@xtablo/ui/components/button"; import { @@ -8,6 +9,7 @@ import { CardTitle, } from "@xtablo/ui/components/card"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; +import { useMemo } from "react"; import { useCancelSubscription, useCreateCheckoutSession, @@ -17,8 +19,6 @@ import { useTrialExpiration, } from "../hooks/stripe"; import { useUser } from "../providers/UserStoreProvider"; -import { pluralize } from "@xtablo/shared"; -import { useMemo } from "react"; const allowedInfiniteUsers = [ "arbelleville@gmail.com", diff --git a/apps/main/src/components/TabloPointer.tsx b/apps/main/src/components/TabloPointer.tsx new file mode 100644 index 0000000..14cfddc --- /dev/null +++ b/apps/main/src/components/TabloPointer.tsx @@ -0,0 +1,104 @@ +import { ArrowDown } from "lucide-react"; +import { useEffect, useState } from "react"; + +interface TabloPointerProps { + show: boolean; + tabloId: string; + onDismiss: () => void; +} + +export function TabloPointer({ show, tabloId, onDismiss }: TabloPointerProps) { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + + useEffect(() => { + if (show && tabloId) { + // Wait a bit for the page to render + const timer = setTimeout(() => { + // Find the tablo card element on the page + const tabloElement = document.querySelector(`[data-tablo-id="${tabloId}"]`); + if (tabloElement) { + const rect = tabloElement.getBoundingClientRect(); + // Position the pointer above and centered on the card + setPosition({ + top: rect.top + window.scrollY - 100, + left: rect.left + rect.width / 2, + }); + + // Scroll the element into view + tabloElement.scrollIntoView({ behavior: "smooth", block: "center" }); + } + }, 800); + + // Auto-dismiss after 6 seconds + const dismissTimer = setTimeout(() => { + onDismiss(); + }, 6000); + + return () => { + clearTimeout(timer); + clearTimeout(dismissTimer); + }; + } + }, [show, tabloId, onDismiss]); + + if (!show || !position) return null; + + return ( +
+
+ {/* Floating widget */} +
+
+ + Votre nouveau tablo est prĂȘt ! Cliquez dessus pour l'ouvrir + + +
+
+ + {/* Arrow pointing down */} +
+ +
+ + {/* Glow effect */} +
+
+ + +
+ ); +} diff --git a/apps/main/src/components/UpgradePanel.tsx b/apps/main/src/components/UpgradePanel.tsx index 623076a..0a85310 100644 --- a/apps/main/src/components/UpgradePanel.tsx +++ b/apps/main/src/components/UpgradePanel.tsx @@ -9,8 +9,8 @@ import { import { Text } from "@xtablo/ui/components/typography"; import { AlertCircle, CheckCircle2, CreditCard, Loader2Icon, Sparkles } from "lucide-react"; import { useUpgradeBlock } from "../contexts/UpgradeBlockContext"; -import { useCreateCheckoutSession } from "../hooks/stripe"; import { useLogout } from "../hooks/auth"; +import { useCreateCheckoutSession } from "../hooks/stripe"; /** * Blocking upgrade panel that appears when users are past their trial period diff --git a/apps/main/src/locales/en/onboarding.json b/apps/main/src/locales/en/onboarding.json index 71a6547..24e522b 100644 --- a/apps/main/src/locales/en/onboarding.json +++ b/apps/main/src/locales/en/onboarding.json @@ -1,66 +1,45 @@ { "modal": { - "skipTutorial": "Skip tutorial", + "skipTutorial": "Skip onboarding", "back": "Back", "next": "Next", - "getStarted": "Get Started", + "getStarted": "Create Tablo", "steps": { "welcome": { - "title": "Welcome to Xtablo", - "description": "Your all-in-one platform for seamless client onboarding", + "title": "Welcome to XTablo", + "description": "Here, you will create your first Tablo - a clear, elegant project tracking space shareable with your clients or team.", "content": { - "intro": "Xtablo helps you streamline your client relationships from the very first interaction to ongoing collaboration.", - "features": "Whether you're managing projects, scheduling meetings, or staying in touch with clients, Xtablo brings everything together in one beautiful interface." + "intro": "In less than 2 minutes, you will have a ready-to-use Tablo, structured around your services.", + "cta": "Create my first Tablo" } }, - "tablos": { - "title": "Organize with Tablos", - "description": "Create dedicated spaces for each client or project", - "content": { - "intro": "A Tablo is your workspace for managing a client relationship.", - "feature1": "Keep all client files, documents, and information in one place", - "feature2": "Invite team members and clients to collaborate", - "feature3": "Track progress and manage tasks effortlessly" + "profile": { + "title": "STEP 1: Freelance or Agency?", + "description": "Tell us about your business model", + "question": "You are", + "options": { + "freelance": "Freelance (solo, custom services)", + "agency": "Agency (team, multiple projects, subcontractors)" } }, - "planning": { - "title": "Schedule & Plan", - "description": "Manage your calendar and client availability", - "content": { - "intro": "Never miss a meeting or deadline with integrated planning tools.", - "feature1": "Create events and sync them across your team", - "feature2": "Set your availability and let clients book time with you", - "feature3": "Get notifications for upcoming events" - } + "service": { + "title": "STEP 2: What is your main service?", + "description": "Define the service you want to track in this Tablo", + "question": "What is your service?", + "placeholder": "Ex: Web Development, Consulting..." }, - "chat": { - "title": "Communicate & Collaborate", - "description": "Built-in chat and notes for seamless teamwork", - "content": { - "intro": "Keep conversations organized and accessible to everyone who needs them.", - "feature1": "Real-time chat with your team and clients", - "feature2": "Share notes and important updates", - "feature3": "Keep a history of all project communications" - } + "structure": { + "title": "STEP 3: How many steps structure your service?", + "description": "Break down your service into workflow steps", + "question": "How many steps make up your service?", + "steps_label": "Step name (ex: Brief -> Design -> Delivery)", + "step_placeholder": "Step {{index}}" }, - "ready": { - "title": "Ready to Get Started?", - "description": "Let's create your first Tablo and onboard your first client", - "content": { - "intro": "You're all set! Here are some quick actions to get you started:", - "action1": { - "title": "1. Create your first Tablo", - "description": "Click \"New Tablo\" to create a workspace for your first client" - }, - "action2": { - "title": "2. Invite your team", - "description": "Add team members to collaborate on client projects" - }, - "action3": { - "title": "3. Explore the features", - "description": "Check out Planning, Chat, and Notes to see what Xtablo can do" - } - } + "project": { + "title": "Step 4: Project Name (Tablo)", + "description": "Give your first Tablo a name", + "question": "What would you like to call this project?", + "placeholder": "ex: Landing Page - Client X" } } } diff --git a/apps/main/src/locales/fr/onboarding.json b/apps/main/src/locales/fr/onboarding.json index 07dcebf..75b2eba 100644 --- a/apps/main/src/locales/fr/onboarding.json +++ b/apps/main/src/locales/fr/onboarding.json @@ -1,66 +1,45 @@ { "modal": { - "skipTutorial": "Passer le tutoriel", + "skipTutorial": "Passer l'onboarding", "back": "Retour", "next": "Suivant", - "getStarted": "Commencer", + "getStarted": "CrĂ©er le Tablo", "steps": { "welcome": { - "title": "Bienvenue sur Xtablo", - "description": "Votre plateforme tout-en-un pour l'intĂ©gration fluide de vos clients", + "title": "Bienvenue sur XTablo", + "description": "Ici, vous allez crĂ©er votre premier Tablo - un espace de suivi projet, clair, Ă©lĂ©gant et partageable avec vos clients ou votre Ă©quipe.", "content": { - "intro": "Xtablo vous aide Ă  rationaliser vos relations clients de la premiĂšre interaction Ă  la collaboration continue.", - "features": "Que vous gĂ©riez des projets, planifiez des rĂ©unions ou restiez en contact avec vos clients, Xtablo rassemble tout dans une interface Ă©lĂ©gante." + "intro": "En moins de 2 minutes, vous aurez un Tablo prĂȘt Ă  l'emploi, structurĂ© autour de vos services.", + "cta": "CrĂ©er mon premier Tablo" } }, - "tablos": { - "title": "Organisez avec les Tablos", - "description": "CrĂ©ez des espaces dĂ©diĂ©s pour chaque client ou projet", - "content": { - "intro": "Un Tablo est votre espace de travail pour gĂ©rer une relation client.", - "feature1": "Conservez tous les fichiers, documents et informations clients au mĂȘme endroit", - "feature2": "Invitez des membres de l'Ă©quipe et des clients Ă  collaborer", - "feature3": "Suivez l'avancement et gĂ©rez les tĂąches sans effort" + "profile": { + "title": "ÉTAPE 1 : Freelance ou Agence ?", + "description": "Parlez-nous de votre modĂšle d'activitĂ©", + "question": "Vous ĂȘtes", + "options": { + "freelance": "Freelance (solo, prestations personnalisĂ©es)", + "agency": "Agence (Ă©quipe, plusieurs projets, sous-traitants)" } }, - "planning": { - "title": "Planifiez & Organisez", - "description": "GĂ©rez votre calendrier et les disponibilitĂ©s de vos clients", - "content": { - "intro": "Ne manquez jamais une rĂ©union ou une Ă©chĂ©ance grĂące aux outils de planification intĂ©grĂ©s.", - "feature1": "CrĂ©ez des Ă©vĂ©nements et synchronisez-les avec votre Ă©quipe", - "feature2": "DĂ©finissez vos disponibilitĂ©s et laissez les clients rĂ©server du temps avec vous", - "feature3": "Recevez des notifications pour les Ă©vĂ©nements Ă  venir" - } + "service": { + "title": "ÉTAPE 2 : Quel est votre service principal ?", + "description": "DĂ©finissez le service que vous souhaitez suivre dans ce Tablo", + "question": "Quel est votre service ?", + "placeholder": "Ex: CrĂ©ation de site web, Consulting..." }, - "chat": { - "title": "Communiquez & Collaborez", - "description": "Chat et notes intĂ©grĂ©s pour un travail d'Ă©quipe fluide", - "content": { - "intro": "Gardez les conversations organisĂ©es et accessibles Ă  tous ceux qui en ont besoin.", - "feature1": "Chat en temps rĂ©el avec votre Ă©quipe et vos clients", - "feature2": "Partagez des notes et des mises Ă  jour importantes", - "feature3": "Conservez un historique de toutes les communications du projet" - } + "structure": { + "title": "ÉTAPE 3 : En combien d'Ă©tapes structurez-vous votre service ?", + "description": "DĂ©composez votre service en Ă©tapes de workflow", + "question": "Combien d'Ă©tapes composent votre service ?", + "steps_label": "Nom de l'Ă©tape (ex : Brief -> Design -> Livraison)", + "step_placeholder": "Étape {{index}}" }, - "ready": { - "title": "PrĂȘt Ă  commencer ?", - "description": "CrĂ©ons votre premier Tablo et intĂ©grons votre premier client", - "content": { - "intro": "Vous ĂȘtes prĂȘt ! Voici quelques actions rapides pour commencer :", - "action1": { - "title": "1. CrĂ©ez votre premier Tablo", - "description": "Cliquez sur \"Nouveau Tablo\" pour crĂ©er un espace de travail pour votre premier client" - }, - "action2": { - "title": "2. Invitez votre Ă©quipe", - "description": "Ajoutez des membres de l'Ă©quipe pour collaborer sur les projets clients" - }, - "action3": { - "title": "3. Explorez les fonctionnalitĂ©s", - "description": "DĂ©couvrez la Planification, le Chat et les Notes pour voir ce que Xtablo peut faire" - } - } + "project": { + "title": "Étape 4 : Nom du projet (tablo)", + "description": "Donnez un nom Ă  votre premier Tablo", + "question": "Comment souhaitez-vous appeler ce projet ?", + "placeholder": "ex : Landing Page - Client X" } } } diff --git a/apps/main/src/pages/tablo.tsx b/apps/main/src/pages/tablo.tsx index 7b4ce89..223f2fe 100644 --- a/apps/main/src/pages/tablo.tsx +++ b/apps/main/src/pages/tablo.tsx @@ -19,6 +19,7 @@ import { SelectTrigger, SelectValue, } from "@xtablo/ui/components/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip"; import { Text, TypographyH3, TypographyMuted } from "@xtablo/ui/components/typography"; import { CheckCircle2, @@ -37,7 +38,6 @@ import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { useCanCreateTablo, useCreateTablo, useDeleteTablo, useTablosList } from "../hooks/tablos"; import { useIsReadOnlyUser } from "../providers/UserStoreProvider"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@xtablo/ui/components/tooltip"; type FilterOption = { id: "all" | "todo" | "inProgress" | "done"; @@ -322,6 +322,7 @@ export const TabloPage = () => {
{ e.preventDefault(); // Show context menu for all users @@ -457,6 +458,7 @@ export const TabloPage = () => {
{ e.preventDefault(); setContextMenuTablo(contextMenuTablo === tablo.id ? null : tablo.id); diff --git a/packages/ui/package.json b/packages/ui/package.json index 4514cae..afb4ea6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,7 +15,6 @@ "format": "biome format --write ." }, "dependencies": { - "@xtablo/shared": "workspace:*", "@floating-ui/react": "^0.27.4", "@internationalized/date": "^3.7.0", "@radix-ui/react-avatar": "^1.1.10", @@ -25,6 +24,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slider": "^1.3.6", @@ -34,6 +34,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@react-aria/i18n": "^3.12.7", "@react-stately/calendar": "^3.7.1", + "@xtablo/shared": "workspace:*", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -49,9 +50,9 @@ }, "devDependencies": { "@biomejs/biome": "2.2.5", + "@tailwindcss/cli": "^4.1.5", "@types/react": "19.0.10", "@types/react-dom": "19.0.4", - "@tailwindcss/cli": "^4.1.5", "tailwindcss": "^4.1.15", "typescript": "^5.7.0" } diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 08c48ab..38d49b3 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -18,6 +18,7 @@ export * from "./input"; export * from "./label"; export * from "./popover"; export * from "./progress"; +export * from "./radio-group"; export * from "./select"; export * from "./separator"; export * from "./slider"; diff --git a/packages/ui/src/components/radio-group.tsx b/packages/ui/src/components/radio-group.tsx new file mode 100644 index 0000000..4f5627d --- /dev/null +++ b/packages/ui/src/components/radio-group.tsx @@ -0,0 +1,44 @@ +"use client"; + +import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"; +import { cn } from "@xtablo/shared"; +import { CircleIcon } from "lucide-react"; +import type * as React from "react"; + +function RadioGroup({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function RadioGroupItem({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + + + ); +} + +export { RadioGroup, RadioGroupItem }; diff --git a/packages/ui/src/components/tooltip.tsx b/packages/ui/src/components/tooltip.tsx index b20ad09..96faa82 100644 --- a/packages/ui/src/components/tooltip.tsx +++ b/packages/ui/src/components/tooltip.tsx @@ -1,9 +1,8 @@ "use client"; -import * as React from "react"; import * as TooltipPrimitive from "@radix-ui/react-tooltip"; - import { cn } from "@xtablo/shared"; +import type * as React from "react"; function TooltipProvider({ delayDuration = 0, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 822cd74..566edfb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -613,6 +613,9 @@ importers: '@radix-ui/react-popover': specifier: ^1.1.15 version: 1.1.15(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-radio-group': + specifier: ^1.3.8 + version: 1.3.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@radix-ui/react-select': specifier: ^2.2.6 version: 2.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -2485,6 +2488,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-radio-group@1.3.8': + resolution: {integrity: sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-roving-focus@1.1.11': resolution: {integrity: sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA==} peerDependencies: @@ -10872,6 +10888,24 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-radio-group@1.3.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.3 + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-direction': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-presence': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-roving-focus': 1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-previous': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-size': 1.1.1(@types/react@19.0.10)(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-roving-focus@1.1.11(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': dependencies: '@radix-ui/primitive': 1.1.3 diff --git a/supabase/migrations/20251202203228_fix_tablo_status.sql b/supabase/migrations/20251202203228_fix_tablo_status.sql new file mode 100644 index 0000000..6d3f3dc --- /dev/null +++ b/supabase/migrations/20251202203228_fix_tablo_status.sql @@ -0,0 +1,63 @@ +-- Fix compute_tablo_status to return 'todo' when there are no tasks +-- This migration updates the function to return 'todo' instead of 'done' +-- when a tablo has etapes but no child tasks + +CREATE OR REPLACE FUNCTION "public"."compute_tablo_status"("tablo_id_param" "text") +RETURNS "text" +LANGUAGE "plpgsql" +STABLE +AS $$ +DECLARE + etape_count INTEGER; + total_tasks INTEGER; + done_tasks INTEGER; + in_progress_tasks INTEGER; + computed_status TEXT; +BEGIN + -- Count total etapes for this tablo + SELECT COUNT(*) + INTO etape_count + FROM "public"."tasks" + WHERE "tablo_id" = tablo_id_param + AND "is_parent" = true; + + -- If no etapes exist, return 'todo' + IF etape_count = 0 THEN + RETURN 'todo'; + END IF; + + -- Count tasks across all etapes (excluding parent tasks) + SELECT + COUNT(*), + COUNT(CASE WHEN "status" = 'done' THEN 1 END), + COUNT(CASE WHEN "status" IN ('in_progress', 'in_review') THEN 1 END) + INTO total_tasks, done_tasks, in_progress_tasks + FROM "public"."tasks" + WHERE "tablo_id" = tablo_id_param + AND "is_parent" = false; + + -- If no child tasks exist, return 'todo' (no tasks present) + IF total_tasks = 0 THEN + RETURN 'todo'; + END IF; + + -- Determine status based on task counts + -- Priority order: done > in_progress > todo + IF done_tasks = total_tasks THEN + -- All tasks are done + computed_status := 'done'; + ELSIF in_progress_tasks > 0 THEN + -- At least one task is actively in progress or in review + computed_status := 'in_progress'; + ELSIF done_tasks > 0 THEN + -- Some tasks are done but none are in progress (showing progress) + computed_status := 'in_progress'; + ELSE + -- All tasks are todo (no progress has been made) + computed_status := 'todo'; + END IF; + + RETURN computed_status; +END; +$$; + diff --git a/supabase/tests/database/12_compute_tablo_status.test.sql b/supabase/tests/database/12_compute_tablo_status.test.sql index 5a85827..5b7f316 100644 --- a/supabase/tests/database/12_compute_tablo_status.test.sql +++ b/supabase/tests/database/12_compute_tablo_status.test.sql @@ -1,5 +1,5 @@ BEGIN; -SELECT plan(15); +SELECT plan(16); -- ============================================================================ -- Setup Test Data @@ -185,13 +185,44 @@ SELECT is( ); -- ============================================================================ --- Test 6: Tablo with empty etapes (no child tasks) should return 'done' +-- Test 6: Tablo with empty etapes (no child tasks) should return 'todo' +-- Updated: Now returns 'todo' when there are no tasks present (etapes exist but no child tasks) -- ============================================================================ SELECT is( public.compute_tablo_status(current_setting('test.tablo_empty_etapes')), - 'done', - 'Tablo with etapes that have no child tasks should have status "done"' + 'todo', + 'Tablo with etapes that have no child tasks should have status "todo" (no tasks present)' +); + +-- ============================================================================ +-- Test 6.25: Explicit test for no tasks present - should return 'todo' +-- ============================================================================ + +DO $$ +DECLARE + no_tasks_tablo_id text; + no_tasks_etape_id text; +BEGIN + -- Create a tablo with etapes but explicitly no child tasks + INSERT INTO public.tablos (owner_id, name, position) + VALUES (current_setting('test.user_id')::uuid, 'No Tasks Tablo', 8) + RETURNING id INTO no_tasks_tablo_id; + + -- Create an etape (parent task) + INSERT INTO public.tasks (tablo_id, title, status, position, is_parent) + VALUES (no_tasks_tablo_id, 'Etape Without Tasks', 'todo', 0, true) + RETURNING id INTO no_tasks_etape_id; + + -- Explicitly do NOT create any child tasks + + PERFORM set_config('test.no_tasks_tablo', no_tasks_tablo_id, true); +END $$; + +SELECT is( + public.compute_tablo_status(current_setting('test.no_tasks_tablo')), + 'todo', + 'Tablo with etapes but no child tasks should return "todo" (fix: no tasks present)' ); -- ============================================================================