commit
a7046a341a
19 changed files with 1103 additions and 298 deletions
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>) =>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
|
|
|||
110
apps/main/src/components/OnboardingCelebration.tsx
Normal file
110
apps/main/src/components/OnboardingCelebration.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
104
apps/main/src/components/TabloPointer.tsx
Normal file
104
apps/main/src/components/TabloPointer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
44
packages/ui/src/components/radio-group.tsx
Normal file
44
packages/ui/src/components/radio-group.tsx
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
63
supabase/migrations/20251202203228_fix_tablo_status.sql
Normal file
63
supabase/migrations/20251202203228_fix_tablo_status.sql
Normal 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;
|
||||
$$;
|
||||
|
||||
|
|
@ -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)'
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue