feat: complete HookLab MVP - TikTok Shop coaching platform
Full-stack Next.js 15 application with: - Landing page with marketing components (Hero, Testimonials, Pricing, FAQ) - Multi-step candidature form with API route - Stripe Checkout integration (subscription + webhooks) - Supabase Auth (login/register) with middleware protection - Dashboard with progress tracking and module system - Formations pages with completion tracking - Profile management with password change - Database schema with RLS policies - Resend email integration for transactional emails Stack: Next.js 15, TypeScript, Tailwind CSS v4, Supabase, Stripe, Resend https://claude.ai/code/session_01H2aRGDaKgarPvhay2HxN6Y
This commit is contained in:
83
app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx
Normal file
83
app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface MarkCompleteButtonProps {
|
||||
moduleId: string;
|
||||
userId: string;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export default function MarkCompleteButton({
|
||||
moduleId,
|
||||
userId,
|
||||
isCompleted: initialCompleted,
|
||||
}: MarkCompleteButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [completed, setCompleted] = useState(initialCompleted);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
if (completed) {
|
||||
// Marquer comme non complété
|
||||
await (supabase
|
||||
.from("user_progress")
|
||||
.update({ completed: false, completed_at: null } as never)
|
||||
.eq("user_id", userId)
|
||||
.eq("module_id", moduleId));
|
||||
} else {
|
||||
// Marquer comme complété (upsert)
|
||||
await (supabase.from("user_progress").upsert({
|
||||
user_id: userId,
|
||||
module_id: moduleId,
|
||||
completed: true,
|
||||
completed_at: new Date().toISOString(),
|
||||
} as never));
|
||||
}
|
||||
|
||||
setCompleted(!completed);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("Erreur mise a jour progression:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleToggle}
|
||||
loading={loading}
|
||||
variant={completed ? "secondary" : "primary"}
|
||||
>
|
||||
{completed ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-success"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Complete - Annuler
|
||||
</span>
|
||||
) : (
|
||||
"Marquer comme complete"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
181
app/(dashboard)/formations/[moduleId]/page.tsx
Normal file
181
app/(dashboard)/formations/[moduleId]/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import Card from "@/components/ui/Card";
|
||||
import MarkCompleteButton from "./MarkCompleteButton";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
interface ModulePageProps {
|
||||
params: Promise<{ moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function ModulePage({ params }: ModulePageProps) {
|
||||
const { moduleId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Récupérer le module
|
||||
const { data: module } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("id", moduleId)
|
||||
.eq("is_published", true)
|
||||
.single() as { data: Module | null };
|
||||
|
||||
if (!module) {
|
||||
redirect("/formations");
|
||||
}
|
||||
|
||||
// Récupérer la progression pour ce module
|
||||
const { data: progress } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("module_id", moduleId)
|
||||
.single() as { data: UserProgress | null };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm mb-8">
|
||||
<a
|
||||
href="/formations"
|
||||
className="text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
Formations
|
||||
</a>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="text-white/40">Semaine {module.week_number}</span>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="text-white">{module.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-primary/10 rounded-lg text-primary text-xs font-medium">
|
||||
Semaine {module.week_number}
|
||||
</span>
|
||||
{module.content_type && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-dark-lighter rounded-lg text-white/40 text-xs font-medium uppercase">
|
||||
{module.content_type}
|
||||
</span>
|
||||
)}
|
||||
{module.duration_minutes && (
|
||||
<span className="text-white/30 text-xs">
|
||||
{module.duration_minutes} min
|
||||
</span>
|
||||
)}
|
||||
{progress?.completed && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-success/10 rounded-lg text-success text-xs font-medium">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-3">{module.title}</h1>
|
||||
{module.description && (
|
||||
<p className="text-white/60 text-lg">{module.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenu du module */}
|
||||
<Card className="mb-8">
|
||||
{/* Video */}
|
||||
{module.content_type === "video" && module.content_url && (
|
||||
<div className="aspect-video bg-dark-lighter rounded-2xl overflow-hidden mb-6">
|
||||
<iframe
|
||||
src={module.content_url}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title={module.title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF */}
|
||||
{module.content_type === "pdf" && module.content_url && (
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={module.content_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-primary/10 text-primary rounded-xl hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Telecharger le PDF
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder si pas de contenu */}
|
||||
{!module.content_url && (
|
||||
<div className="aspect-video bg-dark-lighter rounded-2xl flex items-center justify-center mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-3">🎬</div>
|
||||
<p className="text-white/40 text-sm">
|
||||
Le contenu sera bientot disponible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="/formations"
|
||||
className="text-white/40 hover:text-white text-sm transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Retour aux formations
|
||||
</a>
|
||||
|
||||
<MarkCompleteButton
|
||||
moduleId={moduleId}
|
||||
userId={user!.id}
|
||||
isCompleted={progress?.completed || false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
app/(dashboard)/formations/page.tsx
Normal file
108
app/(dashboard)/formations/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import ModuleCard from "@/components/dashboard/ModuleCard";
|
||||
import ProgressBar from "@/components/dashboard/ProgressBar";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
export default async function FormationsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Récupérer les modules publiés
|
||||
const { data: modules } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("is_published", true)
|
||||
.order("week_number", { ascending: true })
|
||||
.order("order_index", { ascending: true }) as { data: Module[] | null };
|
||||
|
||||
// Récupérer la progression
|
||||
const { data: progress } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id) as { data: UserProgress[] | null };
|
||||
|
||||
// Grouper les modules par semaine
|
||||
const modulesByWeek = (modules || []).reduce(
|
||||
(acc, module) => {
|
||||
const week = module.week_number;
|
||||
if (!acc[week]) acc[week] = [];
|
||||
acc[week].push(module);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, Module[]>
|
||||
);
|
||||
|
||||
const totalModules = modules?.length || 0;
|
||||
const completedModules =
|
||||
progress?.filter((p) => p.completed).length || 0;
|
||||
const progressPercent =
|
||||
totalModules > 0 ? (completedModules / totalModules) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Formations</h1>
|
||||
<p className="text-white/60 mb-6">
|
||||
Progression dans le programme HookLab - 8 semaines.
|
||||
</p>
|
||||
<ProgressBar
|
||||
value={progressPercent}
|
||||
label={`${completedModules} modules completes sur ${totalModules}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modules par semaine */}
|
||||
{Object.entries(modulesByWeek).map(([week, weekModules]) => {
|
||||
const weekCompleted =
|
||||
weekModules?.filter((m) =>
|
||||
progress?.find((p) => p.module_id === m.id && p.completed)
|
||||
).length || 0;
|
||||
const weekTotal = weekModules?.length || 0;
|
||||
|
||||
return (
|
||||
<div key={week} className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Semaine {week}
|
||||
</h2>
|
||||
<span className="text-white/30 text-sm">
|
||||
{weekCompleted}/{weekTotal} completes
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{weekModules?.map((module) => {
|
||||
const moduleProgress = progress?.find(
|
||||
(p) => p.module_id === module.id
|
||||
);
|
||||
return (
|
||||
<ModuleCard
|
||||
key={module.id}
|
||||
module={module}
|
||||
progress={moduleProgress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Message si aucun module */}
|
||||
{totalModules === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-5xl mb-4">📚</div>
|
||||
<h3 className="text-white font-semibold text-lg mb-2">
|
||||
Aucun module disponible
|
||||
</h3>
|
||||
<p className="text-white/40 text-sm">
|
||||
Les modules de formation seront bientot disponibles.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user