Merge pull request #52 from artslidd/develop

Onboarding
This commit is contained in:
Arthur Belleville 2025-12-02 21:53:18 +01:00 committed by GitHub
commit a7046a341a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1103 additions and 298 deletions

View file

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

View file

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

View file

@ -60,7 +60,7 @@ const createTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getI
await supabase.from("events").insert(eventsToInsert);
}
return c.json({ message: "Tablo created successfully" });
return c.json({ message: "Tablo created successfully", tablo: tabloData });
});
const updateTablo = (middlewareManager: ReturnType<typeof MiddlewareManager.getInstance>) =>

View file

@ -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);
}
}, []);

View file

@ -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 (
<div className="fixed inset-0 pointer-events-none z-[100] overflow-hidden">
{/* Confetti particles */}
{confetti.map((particle) => (
<div
key={particle.id}
className="absolute top-0 w-2 h-2 rounded-full animate-confetti-fall"
style={{
left: `${particle.left}%`,
animationDelay: `${particle.delay}s`,
animationDuration: `${particle.duration}s`,
backgroundColor: [
"rgb(239, 68, 68)", // red
"rgb(59, 130, 246)", // blue
"rgb(34, 197, 94)", // green
"rgb(251, 191, 36)", // yellow
"rgb(168, 85, 247)", // purple
"rgb(236, 72, 153)", // pink
][Math.floor(Math.random() * 6)],
}}
/>
))}
{/* Success message overlay */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="bg-background/95 backdrop-blur-sm border-2 border-primary shadow-2xl rounded-2xl p-8 animate-scale-in">
<div className="flex flex-col items-center gap-4">
<div className="p-4 bg-primary/10 rounded-full animate-pulse">
<Sparkles className="h-12 w-12 text-primary" />
</div>
<div className="text-center space-y-2">
<h2 className="text-3xl font-bold">🎉 Félicitations !</h2>
<p className="text-muted-foreground text-lg">Votre Tablo a é créé avec succès</p>
</div>
</div>
</div>
</div>
<style>{`
@keyframes confetti-fall {
0% {
transform: translateY(-10vh) rotate(0deg);
opacity: 1;
}
100% {
transform: translateY(110vh) rotate(720deg);
opacity: 0;
}
}
@keyframes scale-in {
0% {
transform: scale(0.8);
opacity: 0;
}
50% {
transform: scale(1.05);
}
100% {
transform: scale(1);
opacity: 1;
}
}
.animate-confetti-fall {
animation: confetti-fall linear forwards;
}
.animate-scale-in {
animation: scale-in 0.5s ease-out forwards;
}
`}</style>
</div>
);
}

View file

@ -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 (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.welcome.content.intro")}</p>
<div className="bg-accent/50 rounded-lg p-4">
<p className="text-sm">{t("modal.steps.welcome.content.features")}</p>
</div>
</div>
);
}
if (stepKey === "tablos") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">
<Trans
i18nKey="modal.steps.tablos.content.intro"
ns="onboarding"
components={{ strong: <strong /> }}
/>
</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.tablos.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "planning") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.planning.content.intro")}</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.planning.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "chat") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.chat.content.intro")}</p>
<ul className="space-y-2 text-sm text-muted-foreground">
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature1")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature2")}</span>
</li>
<li className="flex items-start gap-2">
<CheckCircle className="h-4 w-4 mt-0.5 text-primary flex-shrink-0" />
<span>{t("modal.steps.chat.content.feature3")}</span>
</li>
</ul>
</div>
);
}
if (stepKey === "ready") {
return (
<div className="space-y-4">
<p className="text-muted-foreground">{t("modal.steps.ready.content.intro")}</p>
<div className="space-y-3">
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action1.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action1.description")}
</p>
</div>
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action2.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action2.description")}
</p>
</div>
<div className="bg-accent/50 rounded-lg p-3 text-sm">
<p className="font-medium mb-1">{t("modal.steps.ready.content.action3.title")}</p>
<p className="text-muted-foreground text-xs">
{t("modal.steps.ready.content.action3.description")}
</p>
</div>
</div>
</div>
);
}
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<string | null>(null);
const STEP_KEYS = ["welcome", "tablos", "planning", "chat", "ready"];
// Form state
const [profileType, setProfileType] = useState<ProfileType>(null);
const [serviceName, setServiceName] = useState("");
const [stepNames, setStepNames] = useState<string[]>(["", "", ""]);
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 (
<Dialog open={open} onOpenChange={(open) => !open && handleSkip()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<div className="flex items-center gap-3 mb-2">
<div className="p-2 bg-primary/10 rounded-lg">
<Icon className="h-6 w-6 text-primary" />
</div>
<div className="flex-1">
<DialogTitle className="text-xl">{stepData.title}</DialogTitle>
<DialogDescription>{stepData.description}</DialogDescription>
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 (
<div className="space-y-6 py-8">
<div className="text-center space-y-4">
<div className="mx-auto w-20 h-20 bg-linear-to-br from-primary/20 to-primary/5 rounded-2xl flex items-center justify-center">
<Sparkles className="h-10 w-10 text-primary" />
</div>
<p className="text-muted-foreground text-lg max-w-md mx-auto leading-relaxed">
{t("modal.steps.welcome.content.intro")}
</p>
</div>
</DialogHeader>
<div className="py-6">
<StepContent stepKey={stepData.key} />
</div>
);
}
{/* Progress indicators */}
<div className="flex gap-1.5 justify-center mb-4">
{STEP_KEYS.map((_, index) => (
<div
key={index}
className={`h-1.5 rounded-full transition-all ${
index === currentStep
? "w-8 bg-primary"
: index < currentStep
? "w-1.5 bg-primary/50"
: "w-1.5 bg-muted"
}`}
if (stepKey === "profile") {
return (
<div className="space-y-6 py-4">
<div className="space-y-5">
<Label className="text-base font-semibold">{t("modal.steps.profile.question")}</Label>
<RadioGroup
value={profileType || ""}
onValueChange={(value: string) => setProfileType(value as ProfileType)}
className="space-y-3"
>
<label
htmlFor="freelance"
className={`flex items-center space-x-4 rounded-xl border-2 p-5 cursor-pointer transition-all ${
profileType === "freelance"
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-accent/50"
}`}
>
<RadioGroupItem value="freelance" id="freelance" />
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-primary/10 rounded-lg">
<Briefcase className="h-5 w-5 text-primary" />
</div>
<span className="font-medium">{t("modal.steps.profile.options.freelance")}</span>
</div>
</label>
<label
htmlFor="agency"
className={`flex items-center space-x-4 rounded-xl border-2 p-5 cursor-pointer transition-all ${
profileType === "agency"
? "border-primary bg-primary/5 shadow-sm"
: "border-border hover:border-primary/50 hover:bg-accent/50"
}`}
>
<RadioGroupItem value="agency" id="agency" />
<div className="flex items-center gap-3 flex-1">
<div className="p-2 bg-primary/10 rounded-lg">
<Building2 className="h-5 w-5 text-primary" />
</div>
<span className="font-medium">{t("modal.steps.profile.options.agency")}</span>
</div>
</label>
</RadioGroup>
</div>
</div>
);
}
if (stepKey === "service") {
return (
<div className="space-y-6 py-4">
<div className="space-y-4">
<Label htmlFor="service" className="text-base font-semibold">
{t("modal.steps.service.question")}
</Label>
<Input
id="service"
value={serviceName}
onChange={(e) => setServiceName(e.target.value)}
placeholder={t("modal.steps.service.placeholder")}
className="text-base h-12 px-4"
autoFocus
/>
))}
</div>
</div>
);
}
<DialogFooter className="flex-row justify-between sm:justify-between">
<Button variant="ghost" onClick={handleSkip} className="text-muted-foreground">
{t("modal.skipTutorial")}
</Button>
<div className="flex gap-2">
{!isFirstStep && (
<Button variant="outline" onClick={handleBack}>
{t("modal.back")}
</Button>
)}
<Button onClick={handleNext}>
{isLastStep ? t("modal.getStarted") : t("modal.next")}
if (stepKey === "structure") {
return (
<div className="space-y-6 py-4">
<div className="space-y-5">
<div>
<Label className="text-base font-semibold">
{t("modal.steps.structure.question")}
</Label>
<p className="text-sm text-muted-foreground mt-1.5">
{t("modal.steps.structure.steps_label")}
</p>
</div>
<div className="space-y-2.5 max-h-[280px] overflow-y-auto pr-1">
{stepNames.map((step, index) => (
<div key={index} className="flex gap-2 items-center group">
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-semibold shrink-0">
{index + 1}
</div>
<Input
value={step}
onChange={(e) => updateStepName(index, e.target.value)}
placeholder={t("modal.steps.structure.step_placeholder", { index: index + 1 })}
className="flex-1 h-11"
/>
{stepNames.length > 2 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeStep(index)}
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
)}
</div>
))}
</div>
<Button
type="button"
variant="outline"
onClick={addStep}
className="w-full h-11 border-dashed"
disabled={stepNames.length >= 10}
>
<Plus className="h-4 w-4 mr-2" />
Ajouter une étape
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
if (stepKey === "project") {
return (
<div className="space-y-6 py-4">
<div className="space-y-4">
<Label htmlFor="project" className="text-base font-semibold">
{t("modal.steps.project.question")}
</Label>
<Input
id="project"
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
placeholder={t("modal.steps.project.placeholder")}
className="text-base h-12 px-4"
autoFocus
/>
{projectName && (
<div className="bg-accent/50 rounded-lg p-4 space-y-2">
<p className="text-sm font-medium">Récapitulatif :</p>
<div className="text-sm text-muted-foreground space-y-1">
<p>
<span className="font-medium">Service :</span> {serviceName}
</p>
<p>
<span className="font-medium">Étapes :</span>{" "}
{stepNames.filter((s) => s.trim()).length} étapes configurées
</p>
</div>
</div>
)}
</div>
</div>
);
}
return null;
};
const renderPreview = () => {
const stepKey = STEP_KEYS[currentStep];
if (stepKey === "welcome") {
return (
<div className="flex flex-col items-center justify-center h-full p-8 animate-fade-in">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 blur-3xl rounded-full animate-pulse" />
<Sparkles className="h-32 w-32 text-primary relative animate-float" />
</div>
<div className="mt-8 text-center space-y-2">
<h3 className="text-2xl font-bold">XTablo</h3>
<p className="text-muted-foreground">Votre espace de gestion projet</p>
</div>
</div>
);
}
if (stepKey === "profile") {
return (
<div className="flex flex-col items-center justify-center h-full p-8 animate-fade-in">
<div className="relative w-full max-w-md">
{/* Modern Illustration */}
{profileType === "freelance" ? (
// Freelance illustration
<div className="relative bg-card border-2 border-primary/20 rounded-3xl p-8 shadow-2xl overflow-hidden mb-6">
<div className="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-primary/5" />
<div className="relative z-10 flex flex-col items-center space-y-6">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 blur-2xl rounded-full animate-pulse" />
<div className="relative bg-primary/10 rounded-full p-8 animate-float">
<Briefcase className="h-20 w-20 text-primary" strokeWidth={1.5} />
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">Freelance</h3>
<p className="text-muted-foreground">
Travail en solo
<br />
Prestations personnalisées
</p>
</div>
<div className="flex gap-2 pt-4">
<div className="h-2 w-12 bg-primary/30 rounded-full" />
<div className="h-2 w-8 bg-primary/20 rounded-full" />
<div className="h-2 w-10 bg-primary/25 rounded-full" />
</div>
</div>
<div className="absolute top-4 right-4 h-16 w-16 bg-primary/5 rounded-full blur-xl" />
<div className="absolute bottom-4 left-4 h-20 w-20 bg-primary/5 rounded-full blur-xl" />
</div>
) : profileType === "agency" ? (
// Agency illustration
<div className="relative bg-card border-2 border-primary/20 rounded-3xl p-8 shadow-2xl overflow-hidden mb-6">
<div className="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-primary/5" />
<div className="relative z-10 flex flex-col items-center space-y-6">
<div className="relative">
<div className="absolute inset-0 bg-primary/20 blur-2xl rounded-full animate-pulse" />
<div className="relative flex items-end justify-center gap-2 animate-float">
<div className="bg-primary/20 rounded-full p-4">
<div className="h-12 w-12 rounded-full bg-primary/40 flex items-center justify-center">
<div className="h-6 w-6 rounded-full bg-primary" />
</div>
</div>
<div className="bg-primary/30 rounded-full p-5 -mb-2">
<div className="h-14 w-14 rounded-full bg-primary/50 flex items-center justify-center">
<div className="h-7 w-7 rounded-full bg-primary" />
</div>
</div>
<div className="bg-primary/20 rounded-full p-4">
<div className="h-12 w-12 rounded-full bg-primary/40 flex items-center justify-center">
<div className="h-6 w-6 rounded-full bg-primary" />
</div>
</div>
</div>
</div>
<div className="text-center space-y-2">
<h3 className="text-2xl font-bold">Agence</h3>
<p className="text-muted-foreground">
Équipe collaborative
<br />
Plusieurs projets simultanés
</p>
</div>
<div className="flex gap-2 pt-4">
<div className="h-2 w-10 bg-primary/30 rounded-full" />
<div className="h-2 w-12 bg-primary/25 rounded-full" />
<div className="h-2 w-8 bg-primary/20 rounded-full" />
<div className="h-2 w-10 bg-primary/25 rounded-full" />
</div>
</div>
<div className="absolute top-4 right-4 h-16 w-16 bg-primary/5 rounded-full blur-xl" />
<div className="absolute bottom-4 left-4 h-20 w-20 bg-primary/5 rounded-full blur-xl" />
</div>
) : (
// Placeholder
<div className="relative bg-card border-2 border-muted/20 rounded-3xl p-8 shadow-xl overflow-hidden mb-6">
<div className="relative z-10 flex flex-col items-center space-y-6">
<div className="bg-muted/30 rounded-full p-12 animate-pulse">
<div className="h-16 w-16 rounded-full bg-muted/50" />
</div>
<div className="text-center space-y-2">
<h3 className="text-xl font-semibold text-muted-foreground">
Choisissez votre profil
</h3>
<p className="text-sm text-muted-foreground">
Sélectionnez votre type d'activité
<br />
pour continuer
</p>
</div>
</div>
</div>
)}
</div>
</div>
);
}
if (stepKey === "service") {
return (
<div className="flex flex-col items-center justify-center h-full p-8 animate-fade-in">
<div className="relative w-full max-w-xs">
<div className="bg-card border-2 border-primary/20 rounded-2xl p-6 shadow-xl">
<div className="flex items-center gap-3 mb-4">
<FolderKanban className="h-8 w-8 text-primary" />
<div className="flex-1">
<div className="font-semibold text-lg">{serviceName || "Votre service"}</div>
<div className="text-sm text-muted-foreground">Service principal</div>
</div>
</div>
<div className="space-y-3">
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-primary" />
<div className="h-2 bg-muted rounded flex-1" />
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-primary" />
<div className="h-2 bg-muted rounded flex-1" />
</div>
<div className="flex items-center gap-2">
<CheckCircle2 className="h-4 w-4 text-primary" />
<div className="h-2 bg-muted rounded flex-1" />
</div>
</div>
</div>
</div>
</div>
);
}
if (stepKey === "structure") {
const validSteps = stepNames.filter((s) => s.trim());
return (
<div className="flex flex-col items-center justify-center h-full p-8 animate-fade-in">
<div className="relative w-full max-w-xs">
<div className="bg-card border-2 border-primary/20 rounded-2xl p-6 shadow-xl">
<div className="flex items-center gap-2 mb-4">
<Building2 className="h-6 w-6 text-primary" />
<div className="font-semibold">Étapes</div>
</div>
<div className="space-y-3">
{validSteps.length > 0 ? (
validSteps.map((step, index) => (
<div
key={index}
className="flex items-center gap-3 animate-slide-in"
style={{ animationDelay: `${index * 100}ms` }}
>
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary text-sm font-semibold shrink-0">
{index + 1}
</div>
<div className="flex-1 bg-accent/50 rounded-lg px-3 py-2 text-sm font-medium truncate">
{step}
</div>
</div>
))
) : (
<>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted animate-pulse" />
<div className="h-8 bg-muted rounded flex-1 animate-pulse" />
</div>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-muted animate-pulse" />
<div className="h-8 bg-muted rounded flex-1 animate-pulse" />
</div>
</>
)}
</div>
</div>
</div>
</div>
);
}
if (stepKey === "project") {
return (
<div className="flex flex-col items-center justify-center h-full p-8 animate-fade-in">
<div className="relative w-full max-w-xs">
<div className="bg-linear-to-br from-primary/20 to-primary/5 border-2 border-primary/30 rounded-2xl p-6 shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<CheckCircle2 className="h-10 w-10 text-primary animate-bounce-gentle" />
<div className="flex-1">
<div className="font-bold text-xl truncate">{projectName || "Nom du projet"}</div>
<div className="text-sm text-muted-foreground">{serviceName || "Service"}</div>
</div>
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Étapes</span>
<span className="font-semibold">{stepNames.filter((s) => s.trim()).length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">Type</span>
<span className="font-semibold capitalize">{profileType || "—"}</span>
</div>
</div>
</div>
</div>
</div>
);
}
return null;
};
return (
<>
<OnboardingCelebration show={showCelebration} onComplete={() => setShowCelebration(false)} />
<TabloPointer
show={showPointer}
tabloId={createdTabloId || ""}
onDismiss={() => setShowPointer(false)}
/>
<Dialog open={open && !showCelebration} onOpenChange={(open) => !open && handleSkip()}>
<DialogContent className="max-w-6xl p-0 gap-0 overflow-hidden">
<div className="flex flex-col md:flex-row min-h-[600px]">
{/* Left side - Form */}
<div className="flex-1 flex flex-col p-8">
<DialogHeader className="mb-6">
<div className="flex items-center gap-4">
<div className="p-3 bg-linear-to-br from-primary/20 to-primary/5 rounded-xl shadow-sm">
<Icon className="h-7 w-7 text-primary" />
</div>
<div className="flex-1">
<DialogTitle className="text-2xl font-bold">
{t(`modal.steps.${STEP_KEYS[currentStep]}.title`)}
</DialogTitle>
<DialogDescription className="text-base mt-1">
{t(`modal.steps.${STEP_KEYS[currentStep]}.description`)}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="flex-1 min-h-[280px]">{renderStepContent()}</div>
{/* Progress indicators */}
<div className="flex gap-2 justify-center my-6">
{STEP_KEYS.map((_, index) => (
<div
key={index}
className={`h-2 rounded-full transition-all duration-300 ${
index === currentStep
? "w-10 bg-primary shadow-sm"
: index < currentStep
? "w-2 bg-primary/60"
: "w-2 bg-muted"
}`}
/>
))}
</div>
<DialogFooter className="flex-row justify-between sm:justify-between gap-3">
<Button
variant="ghost"
onClick={handleSkip}
className="text-muted-foreground hover:text-foreground"
>
{t("modal.skipTutorial")}
</Button>
<div className="flex gap-2">
{!isFirstStep && (
<Button variant="outline" onClick={handleBack} className="min-w-[100px]">
{t("modal.back")}
</Button>
)}
<Button
onClick={handleNext}
disabled={!canProceed() || createTablo.isPending || createEtape.isPending}
className="min-w-[120px]"
>
{createTablo.isPending || createEtape.isPending
? "Création..."
: isLastStep
? t("modal.getStarted")
: t("modal.next")}
</Button>
</div>
</DialogFooter>
</div>
{/* Right side - Animated Preview */}
<div className="hidden md:flex flex-1 bg-linear-to-br from-accent/30 to-accent/10 border-l border-border relative overflow-hidden">
{/* Decorative background elements */}
<div className="absolute top-10 right-10 w-32 h-32 bg-primary/10 rounded-full blur-3xl" />
<div className="absolute bottom-10 left-10 w-40 h-40 bg-primary/5 rounded-full blur-3xl" />
{/* Preview content */}
<div className="relative w-full">{renderPreview()}</div>
</div>
</div>
</DialogContent>
</Dialog>
<style>{`
@keyframes fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
@keyframes float {
0%, 100% {
transform: translateY(0px);
}
50% {
transform: translateY(-20px);
}
}
@keyframes slide-in {
from {
opacity: 0;
transform: translateX(-20px);
}
to {
opacity: 1;
transform: translateX(0);
}
}
.animate-fade-in {
animation: fade-in 0.5s ease-out forwards;
}
.animate-float {
animation: float 3s ease-in-out infinite;
}
.animate-slide-in {
animation: slide-in 0.3s ease-out forwards;
opacity: 0;
}
`}</style>
</>
);
}

View file

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

View file

@ -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 (
<div
className="fixed z-[90] pointer-events-none animate-bounce-gentle"
style={{
top: `${position.top}px`,
left: `${position.left}px`,
transform: "translateX(-50%)",
}}
>
<div className="relative">
{/* Floating widget */}
<div className="bg-primary text-primary-foreground px-4 py-3 rounded-xl shadow-2xl border-2 border-primary/20 pointer-events-auto">
<div className="flex items-center gap-2 whitespace-nowrap">
<span className="text-sm font-semibold">
Votre nouveau tablo est prêt ! Cliquez dessus pour l'ouvrir
</span>
<button
onClick={onDismiss}
className="ml-2 hover:bg-primary-foreground/20 rounded-full p-1 transition-colors"
aria-label="Fermer"
>
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
</div>
{/* Arrow pointing down */}
<div className="absolute left-1/2 -translate-x-1/2 top-full mt-1">
<ArrowDown className="h-8 w-8 text-primary animate-bounce" />
</div>
{/* Glow effect */}
<div className="absolute inset-0 bg-primary/20 blur-xl rounded-xl -z-10" />
</div>
<style>{`
@keyframes bounce-gentle {
0%, 100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(-10px);
}
}
.animate-bounce-gentle {
animation: bounce-gentle 2s ease-in-out infinite;
}
`}</style>
</div>
);
}

View file

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

View file

@ -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 <strong>Tablo</strong> 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"
}
}
}

View file

@ -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 <strong>Tablo</strong> 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"
}
}
}

View file

@ -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 = () => {
<div
key={tablo.id}
className="relative"
data-tablo-id={tablo.id}
onContextMenu={(e) => {
e.preventDefault();
// Show context menu for all users
@ -457,6 +458,7 @@ export const TabloPage = () => {
<div
key={tablo.id}
className="relative"
data-tablo-id={tablo.id}
onContextMenu={(e) => {
e.preventDefault();
setContextMenuTablo(contextMenuTablo === tablo.id ? null : tablo.id);

View file

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

View file

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

View file

@ -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<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
);
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
);
}
export { RadioGroup, RadioGroupItem };

View file

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

View file

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

View file

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

View file

@ -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)'
);
-- ============================================================================