From 41e686c560ae069ed0ced73975ac4d55f455d20e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 8 Feb 2026 12:39:18 +0000 Subject: [PATCH] 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 --- .gitignore | 13 +- README.md | 36 + app/(auth)/login/page.tsx | 116 + app/(auth)/register/page.tsx | 152 + app/(dashboard)/dashboard/page.tsx | 133 + .../[moduleId]/MarkCompleteButton.tsx | 83 + .../formations/[moduleId]/page.tsx | 181 + app/(dashboard)/formations/page.tsx | 108 + app/(dashboard)/layout.tsx | 44 + app/(dashboard)/profil/page.tsx | 240 + app/(marketing)/candidature/page.tsx | 427 + app/(marketing)/merci/page.tsx | 79 + app/(marketing)/page.tsx | 23 + app/api/candidature/route.ts | 132 + app/api/formations/[moduleId]/route.ts | 70 + app/api/stripe/create-checkout/route.ts | 65 + app/api/stripe/webhook/route.ts | 229 + app/globals.css | 92 + app/layout.tsx | 33 + components/dashboard/ModuleCard.tsx | 146 + components/dashboard/ProgressBar.tsx | 36 + components/dashboard/Sidebar.tsx | 156 + components/marketing/FAQ.tsx | 110 + components/marketing/Footer.tsx | 129 + components/marketing/Hero.tsx | 91 + components/marketing/Method.tsx | 123 + components/marketing/Navbar.tsx | 145 + components/marketing/PersonaCards.tsx | 100 + components/marketing/Pricing.tsx | 104 + components/marketing/Testimonials.tsx | 91 + components/ui/Button.tsx | 85 + components/ui/Card.tsx | 32 + components/ui/Input.tsx | 77 + eslint.config.mjs | 18 + lib/stripe/client.ts | 6 + lib/supabase/client.ts | 9 + lib/supabase/middleware.ts | 59 + lib/supabase/server.ts | 45 + lib/utils.ts | 28 + middleware.ts | 18 + next.config.ts | 7 + package-lock.json | 7107 +++++++++++++++++ package.json | 32 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + supabase/migrations/001_initial_schema.sql | 133 + tsconfig.json | 34 + types/database.types.ts | 190 + 52 files changed, 11375 insertions(+), 4 deletions(-) create mode 100644 README.md create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/(auth)/register/page.tsx create mode 100644 app/(dashboard)/dashboard/page.tsx create mode 100644 app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx create mode 100644 app/(dashboard)/formations/[moduleId]/page.tsx create mode 100644 app/(dashboard)/formations/page.tsx create mode 100644 app/(dashboard)/layout.tsx create mode 100644 app/(dashboard)/profil/page.tsx create mode 100644 app/(marketing)/candidature/page.tsx create mode 100644 app/(marketing)/merci/page.tsx create mode 100644 app/(marketing)/page.tsx create mode 100644 app/api/candidature/route.ts create mode 100644 app/api/formations/[moduleId]/route.ts create mode 100644 app/api/stripe/create-checkout/route.ts create mode 100644 app/api/stripe/webhook/route.ts create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 components/dashboard/ModuleCard.tsx create mode 100644 components/dashboard/ProgressBar.tsx create mode 100644 components/dashboard/Sidebar.tsx create mode 100644 components/marketing/FAQ.tsx create mode 100644 components/marketing/Footer.tsx create mode 100644 components/marketing/Hero.tsx create mode 100644 components/marketing/Method.tsx create mode 100644 components/marketing/Navbar.tsx create mode 100644 components/marketing/PersonaCards.tsx create mode 100644 components/marketing/Pricing.tsx create mode 100644 components/marketing/Testimonials.tsx create mode 100644 components/ui/Button.tsx create mode 100644 components/ui/Card.tsx create mode 100644 components/ui/Input.tsx create mode 100644 eslint.config.mjs create mode 100644 lib/stripe/client.ts create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/middleware.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 supabase/migrations/001_initial_schema.sql create mode 100644 tsconfig.json create mode 100644 types/database.types.ts diff --git a/.gitignore b/.gitignore index 45c1abc..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -3,7 +3,12 @@ # dependencies /node_modules /.pnp -.pnp.js +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions # testing /coverage @@ -23,10 +28,10 @@ npm-debug.log* yarn-debug.log* yarn-error.log* +.pnpm-debug.log* -# local env files -.env*.local -.env +# env files (can opt-in for committing if needed) +.env* # vercel .vercel diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). + +## Getting Started + +First, run the development server: + +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` + +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. + +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. + +This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. + +## Learn More + +To learn more about Next.js, take a look at the following resources: + +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. + +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! + +## Deploy on Vercel + +The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. + +Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..03b0ef2 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { createClient } from "@/lib/supabase/client"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; + +export default function LoginPage() { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + try { + const supabase = createClient(); + const { error: authError } = await supabase.auth.signInWithPassword({ + email, + password, + }); + + if (authError) { + if (authError.message.includes("Invalid login credentials")) { + setError("Email ou mot de passe incorrect."); + } else { + setError(authError.message); + } + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Erreur de connexion. Veuillez reessayer."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+ +
+ H +
+ + HookLab + + +

+ Content de te revoir +

+

+ Connecte-toi pour acceder a tes formations. +

+
+ + {/* Form */} +
+
+ setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + + {error && ( +
+

{error}

+
+ )} + + +
+ +
+

+ Pas encore de compte ?{" "} + + Candidater + +

+
+
+
+
+ ); +} diff --git a/app/(auth)/register/page.tsx b/app/(auth)/register/page.tsx new file mode 100644 index 0000000..1477d1c --- /dev/null +++ b/app/(auth)/register/page.tsx @@ -0,0 +1,152 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { createClient } from "@/lib/supabase/client"; +import Button from "@/components/ui/Button"; +import Input from "@/components/ui/Input"; + +export default function RegisterPage() { + const router = useRouter(); + const [fullName, setFullName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleRegister = async (e: React.FormEvent) => { + e.preventDefault(); + setLoading(true); + setError(null); + + if (password !== confirmPassword) { + setError("Les mots de passe ne correspondent pas."); + setLoading(false); + return; + } + + if (password.length < 8) { + setError("Le mot de passe doit contenir au moins 8 caracteres."); + setLoading(false); + return; + } + + try { + const supabase = createClient(); + const { error: authError } = await supabase.auth.signUp({ + email, + password, + options: { + data: { + full_name: fullName, + }, + }, + }); + + if (authError) { + if (authError.message.includes("already registered")) { + setError("Un compte avec cet email existe deja."); + } else { + setError(authError.message); + } + return; + } + + router.push("/dashboard"); + router.refresh(); + } catch { + setError("Erreur lors de l'inscription. Veuillez reessayer."); + } finally { + setLoading(false); + } + }; + + return ( +
+
+ {/* Logo */} +
+ +
+ H +
+ + HookLab + + +

+ Creer ton compte +

+

+ Inscris-toi pour acceder au programme. +

+
+ + {/* Form */} +
+
+ setFullName(e.target.value)} + required + /> + setEmail(e.target.value)} + required + /> + setPassword(e.target.value)} + required + /> + setConfirmPassword(e.target.value)} + required + /> + + {error && ( +
+

{error}

+
+ )} + + +
+ +
+

+ Deja un compte ?{" "} + + Se connecter + +

+
+
+
+
+ ); +} diff --git a/app/(dashboard)/dashboard/page.tsx b/app/(dashboard)/dashboard/page.tsx new file mode 100644 index 0000000..d8f42b6 --- /dev/null +++ b/app/(dashboard)/dashboard/page.tsx @@ -0,0 +1,133 @@ +import { createClient } from "@/lib/supabase/server"; +import Card from "@/components/ui/Card"; +import ProgressBar from "@/components/dashboard/ProgressBar"; +import ModuleCard from "@/components/dashboard/ModuleCard"; +import type { Module, UserProgress, Profile } from "@/types/database.types"; + +export default async function DashboardPage() { + const supabase = await createClient(); + + const { + data: { user }, + } = await supabase.auth.getUser(); + + // Récupérer le profil + const { data: profile } = await supabase + .from("profiles") + .select("*") + .eq("id", user!.id) + .single() as { data: Profile | null }; + + // 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 }; + + const totalModules = modules?.length || 0; + const completedModules = + progress?.filter((p) => p.completed).length || 0; + const progressPercent = + totalModules > 0 ? (completedModules / totalModules) * 100 : 0; + + // Prochain module non complété + const completedIds = new Set( + progress?.filter((p) => p.completed).map((p) => p.module_id) + ); + const nextModules = + modules?.filter((m) => !completedIds.has(m.id)).slice(0, 3) || []; + + return ( +
+ {/* Header */} +
+

+ Bonjour {profile?.full_name?.split(" ")[0] || "!"} 👋 +

+

+ Voici un apercu de ta progression dans le programme. +

+
+ + {/* Stats cards */} +
+ +

Progression globale

+

+ {Math.round(progressPercent)}% +

+ +
+ +

Modules completes

+

+ {completedModules} + + /{totalModules} + +

+
+ +

Statut abonnement

+
+ +

Actif

+
+ {profile?.subscription_end_date && ( +

+ Jusqu'au{" "} + {new Date(profile.subscription_end_date).toLocaleDateString( + "fr-FR" + )} +

+ )} +
+
+ + {/* Prochains modules */} + {nextModules.length > 0 && ( +
+

+ Continue ta formation +

+
+ {nextModules.map((module) => { + const moduleProgress = progress?.find( + (p) => p.module_id === module.id + ); + return ( + + ); + })} +
+
+ )} + + {/* Message si aucun module */} + {totalModules === 0 && ( + +
🚀
+

+ Le programme arrive bientot ! +

+

+ Les modules de formation sont en cours de preparation. Tu seras + notifie des qu'ils seront disponibles. +

+
+ )} +
+ ); +} diff --git a/app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx b/app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx new file mode 100644 index 0000000..33a6d0c --- /dev/null +++ b/app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx @@ -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 ( + + ); +} diff --git a/app/(dashboard)/formations/[moduleId]/page.tsx b/app/(dashboard)/formations/[moduleId]/page.tsx new file mode 100644 index 0000000..39e9540 --- /dev/null +++ b/app/(dashboard)/formations/[moduleId]/page.tsx @@ -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 ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+ + Semaine {module.week_number} + + {module.content_type && ( + + {module.content_type} + + )} + {module.duration_minutes && ( + + {module.duration_minutes} min + + )} + {progress?.completed && ( + + + + + Complete + + )} +
+

{module.title}

+ {module.description && ( +

{module.description}

+ )} +
+ + {/* Contenu du module */} + + {/* Video */} + {module.content_type === "video" && module.content_url && ( +
+