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:
Claude
2026-02-08 12:39:18 +00:00
parent 240b10b2d7
commit 41e686c560
52 changed files with 11375 additions and 4 deletions

13
.gitignore vendored
View File

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

36
README.md Normal file
View File

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

116
app/(auth)/login/page.tsx Normal file
View File

@@ -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<string | null>(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 (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<span className="text-white font-bold">H</span>
</div>
<span className="text-2xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-2xl font-bold text-white mb-2">
Content de te revoir
</h1>
<p className="text-white/60 text-sm">
Connecte-toi pour acceder a tes formations.
</p>
</div>
{/* Form */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
<form onSubmit={handleLogin} className="space-y-5">
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
id="password"
label="Mot de passe"
type="password"
placeholder="Ton mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && (
<div className="p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<Button type="submit" loading={loading} className="w-full">
Se connecter
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-white/40 text-sm">
Pas encore de compte ?{" "}
<Link
href="/candidature"
className="text-primary hover:text-primary-hover transition-colors"
>
Candidater
</Link>
</p>
</div>
</div>
</div>
</main>
);
}

View File

@@ -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<string | null>(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 (
<main className="min-h-screen flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<Link href="/" className="inline-flex items-center gap-2 mb-6">
<div className="w-10 h-10 gradient-bg rounded-xl flex items-center justify-center">
<span className="text-white font-bold">H</span>
</div>
<span className="text-2xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-2xl font-bold text-white mb-2">
Creer ton compte
</h1>
<p className="text-white/60 text-sm">
Inscris-toi pour acceder au programme.
</p>
</div>
{/* Form */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
<form onSubmit={handleRegister} className="space-y-5">
<Input
id="fullName"
label="Nom complet"
placeholder="Jean Dupont"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
required
/>
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
id="password"
label="Mot de passe"
type="password"
placeholder="Minimum 8 caracteres"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<Input
id="confirmPassword"
label="Confirmer le mot de passe"
type="password"
placeholder="Confirme ton mot de passe"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
/>
{error && (
<div className="p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
<Button type="submit" loading={loading} className="w-full">
Creer mon compte
</Button>
</form>
<div className="mt-6 text-center">
<p className="text-white/40 text-sm">
Deja un compte ?{" "}
<Link
href="/login"
className="text-primary hover:text-primary-hover transition-colors"
>
Se connecter
</Link>
</p>
</div>
</div>
</div>
</main>
);
}

View File

@@ -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 (
<div className="max-w-6xl">
{/* Header */}
<div className="mb-10">
<h1 className="text-3xl font-bold text-white mb-2">
Bonjour {profile?.full_name?.split(" ")[0] || "!"} 👋
</h1>
<p className="text-white/60">
Voici un apercu de ta progression dans le programme.
</p>
</div>
{/* Stats cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-10">
<Card>
<p className="text-white/40 text-sm mb-1">Progression globale</p>
<p className="text-2xl font-bold text-white mb-3">
{Math.round(progressPercent)}%
</p>
<ProgressBar value={progressPercent} showPercentage={false} />
</Card>
<Card>
<p className="text-white/40 text-sm mb-1">Modules completes</p>
<p className="text-2xl font-bold text-white">
{completedModules}
<span className="text-white/30 text-lg font-normal">
/{totalModules}
</span>
</p>
</Card>
<Card>
<p className="text-white/40 text-sm mb-1">Statut abonnement</p>
<div className="flex items-center gap-2">
<span className="w-2 h-2 bg-success rounded-full" />
<p className="text-success font-semibold">Actif</p>
</div>
{profile?.subscription_end_date && (
<p className="text-white/30 text-xs mt-1">
Jusqu&apos;au{" "}
{new Date(profile.subscription_end_date).toLocaleDateString(
"fr-FR"
)}
</p>
)}
</Card>
</div>
{/* Prochains modules */}
{nextModules.length > 0 && (
<div>
<h2 className="text-xl font-bold text-white mb-4">
Continue ta formation
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{nextModules.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 && (
<Card className="text-center py-12">
<div className="text-4xl mb-4">🚀</div>
<h3 className="text-white font-semibold text-lg mb-2">
Le programme arrive bientot !
</h3>
<p className="text-white/40 text-sm max-w-md mx-auto">
Les modules de formation sont en cours de preparation. Tu seras
notifie des qu&apos;ils seront disponibles.
</p>
</Card>
)}
</div>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,44 @@
import { redirect } from "next/navigation";
import { createClient } from "@/lib/supabase/server";
import Sidebar from "@/components/dashboard/Sidebar";
import type { Profile } from "@/types/database.types";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const supabase = await createClient();
// Vérifier l'authentification
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
redirect("/login");
}
// Récupérer le profil
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single() as { data: Profile | null };
if (!profile) {
redirect("/login");
}
// Vérifier l'abonnement actif
if (profile.subscription_status !== "active") {
redirect("/login");
}
return (
<div className="flex min-h-screen">
<Sidebar user={profile} />
<main className="flex-1 p-6 md:p-10 overflow-y-auto">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,240 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
import type { Profile } from "@/types/database.types";
export default function ProfilPage() {
const router = useRouter();
const [profile, setProfile] = useState<Profile | null>(null);
const [fullName, setFullName] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{
type: "success" | "error";
text: string;
} | null>(null);
// Changement de mot de passe
const [newPassword, setNewPassword] = useState("");
const [confirmNewPassword, setConfirmNewPassword] = useState("");
const [passwordSaving, setPasswordSaving] = useState(false);
useEffect(() => {
const loadProfile = async () => {
setLoading(true);
const supabase = createClient();
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) return;
const { data } = await supabase
.from("profiles")
.select("*")
.eq("id", user.id)
.single() as { data: Profile | null };
if (data) {
setProfile(data);
setFullName(data.full_name || "");
}
setLoading(false);
};
loadProfile();
}, []);
const handleSaveProfile = async (e: React.FormEvent) => {
e.preventDefault();
setSaving(true);
setMessage(null);
try {
const supabase = createClient();
const { error } = await supabase
.from("profiles")
.update({ full_name: fullName } as never)
.eq("id", profile!.id);
if (error) throw error;
setMessage({ type: "success", text: "Profil mis a jour !" });
router.refresh();
} catch {
setMessage({
type: "error",
text: "Erreur lors de la mise a jour du profil.",
});
} finally {
setSaving(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
setPasswordSaving(true);
setMessage(null);
if (newPassword !== confirmNewPassword) {
setMessage({
type: "error",
text: "Les mots de passe ne correspondent pas.",
});
setPasswordSaving(false);
return;
}
if (newPassword.length < 8) {
setMessage({
type: "error",
text: "Le mot de passe doit contenir au moins 8 caracteres.",
});
setPasswordSaving(false);
return;
}
try {
const supabase = createClient();
const { error } = await supabase.auth.updateUser({
password: newPassword,
});
if (error) throw error;
setMessage({ type: "success", text: "Mot de passe mis a jour !" });
setNewPassword("");
setConfirmNewPassword("");
} catch {
setMessage({
type: "error",
text: "Erreur lors du changement de mot de passe.",
});
} finally {
setPasswordSaving(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin w-8 h-8 border-2 border-primary border-t-transparent rounded-full" />
</div>
);
}
return (
<div className="max-w-2xl">
<h1 className="text-3xl font-bold text-white mb-2">Mon profil</h1>
<p className="text-white/60 mb-10">
Gere tes informations personnelles et ton abonnement.
</p>
{/* Message */}
{message && (
<div
className={`mb-6 p-3 rounded-xl border ${
message.type === "success"
? "bg-success/10 border-success/20 text-success"
: "bg-error/10 border-error/20 text-error"
}`}
>
<p className="text-sm">{message.text}</p>
</div>
)}
{/* Informations profil */}
<Card className="mb-6">
<h2 className="text-lg font-semibold text-white mb-6">
Informations personnelles
</h2>
<form onSubmit={handleSaveProfile} className="space-y-5">
<Input
id="email"
label="Email"
type="email"
value={profile?.email || ""}
disabled
className="opacity-50"
/>
<Input
id="fullName"
label="Nom complet"
placeholder="Jean Dupont"
value={fullName}
onChange={(e) => setFullName(e.target.value)}
/>
<Button type="submit" loading={saving}>
Sauvegarder
</Button>
</form>
</Card>
{/* Changement de mot de passe */}
<Card className="mb-6">
<h2 className="text-lg font-semibold text-white mb-6">
Changer le mot de passe
</h2>
<form onSubmit={handleChangePassword} className="space-y-5">
<Input
id="newPassword"
label="Nouveau mot de passe"
type="password"
placeholder="Minimum 8 caracteres"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
/>
<Input
id="confirmNewPassword"
label="Confirmer le mot de passe"
type="password"
placeholder="Confirme ton nouveau mot de passe"
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
/>
<Button type="submit" loading={passwordSaving} variant="secondary">
Changer le mot de passe
</Button>
</form>
</Card>
{/* Abonnement */}
<Card>
<h2 className="text-lg font-semibold text-white mb-4">Abonnement</h2>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Statut</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 bg-success/10 rounded-lg text-success text-sm font-medium">
<span className="w-1.5 h-1.5 bg-success rounded-full" />
{profile?.subscription_status === "active"
? "Actif"
: "Inactif"}
</span>
</div>
{profile?.subscription_end_date && (
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Valide jusqu&apos;au</span>
<span className="text-white text-sm">
{new Date(profile.subscription_end_date).toLocaleDateString(
"fr-FR",
{ day: "numeric", month: "long", year: "numeric" }
)}
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-white/60 text-sm">Plan</span>
<span className="text-white text-sm">
HookLab - Programme 8 semaines
</span>
</div>
</div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,427 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import Input, { Textarea } from "@/components/ui/Input";
// Étapes du formulaire
type Step = 1 | 2 | 3;
interface FormData {
firstname: string;
email: string;
phone: string;
persona: string;
age: string;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username: string;
}
const initialFormData: FormData = {
firstname: "",
email: "",
phone: "",
persona: "",
age: "",
experience: "",
time_daily: "",
availability: "",
start_date: "",
motivation: "",
monthly_goal: "",
biggest_fear: "",
tiktok_username: "",
};
export default function CandidaturePage() {
const router = useRouter();
const [step, setStep] = useState<Step>(1);
const [formData, setFormData] = useState<FormData>(initialFormData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const updateField = (field: keyof FormData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const canGoNext = (): boolean => {
switch (step) {
case 1:
return !!(
formData.firstname &&
formData.email &&
formData.phone &&
formData.persona &&
formData.age
);
case 2:
return !!(
formData.experience &&
formData.time_daily &&
formData.availability &&
formData.start_date
);
case 3:
return !!(
formData.motivation &&
formData.monthly_goal &&
formData.biggest_fear
);
default:
return false;
}
};
const handleSubmit = async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/candidature", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...formData,
age: parseInt(formData.age, 10),
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Erreur lors de l'envoi");
}
router.push("/merci");
} catch (err) {
setError(err instanceof Error ? err.message : "Erreur inattendue");
} finally {
setLoading(false);
}
};
return (
<main className="min-h-screen py-20 md:py-32">
<div className="max-w-2xl mx-auto px-4 sm:px-6">
{/* Header */}
<div className="text-center mb-12">
<Link href="/" className="inline-flex items-center gap-2 mb-8">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">H</span>
</div>
<span className="text-xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<h1 className="text-3xl md:text-4xl font-bold tracking-[-0.02em] mb-3">
Candidature <span className="gradient-text">HookLab</span>
</h1>
<p className="text-white/60">
Reponds a quelques questions pour qu&apos;on puisse evaluer ton
profil.
</p>
</div>
{/* Progress bar */}
<div className="flex items-center gap-2 mb-10">
{[1, 2, 3].map((s) => (
<div
key={s}
className={`h-1.5 flex-1 rounded-full transition-colors ${
s <= step ? "gradient-bg" : "bg-dark-lighter"
}`}
/>
))}
</div>
{/* Formulaire */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 md:p-8">
{/* Étape 1 : Informations personnelles */}
{step === 1 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Informations personnelles
</h2>
<Input
id="firstname"
label="Prenom"
placeholder="Ton prenom"
value={formData.firstname}
onChange={(e) => updateField("firstname", e.target.value)}
/>
<Input
id="email"
label="Email"
type="email"
placeholder="ton@email.com"
value={formData.email}
onChange={(e) => updateField("email", e.target.value)}
/>
<Input
id="phone"
label="Telephone"
type="tel"
placeholder="06 12 34 56 78"
value={formData.phone}
onChange={(e) => updateField("phone", e.target.value)}
/>
<Input
id="age"
label="Age"
type="number"
placeholder="25"
min="18"
max="65"
value={formData.age}
onChange={(e) => updateField("age", e.target.value)}
/>
{/* Persona selection */}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Tu es plutot...
</label>
<div className="grid grid-cols-2 gap-3">
{[
{
id: "jeune",
label: "Etudiant / Jeune",
emoji: "🎓",
},
{
id: "parent",
label: "Parent / Reconversion",
emoji: "👨‍👩‍👧",
},
].map((p) => (
<button
key={p.id}
type="button"
className={`p-4 rounded-2xl border-2 text-left transition-all cursor-pointer ${
formData.persona === p.id
? "border-primary bg-primary/10"
: "border-dark-border bg-dark-lighter hover:border-primary/30"
}`}
onClick={() => updateField("persona", p.id)}
>
<span className="text-2xl block mb-2">{p.emoji}</span>
<span className="text-white text-sm font-medium">
{p.label}
</span>
</button>
))}
</div>
</div>
</div>
)}
{/* Étape 2 : Situation actuelle */}
{step === 2 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Ta situation actuelle
</h2>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Experience e-commerce / reseaux sociaux
</label>
<div className="space-y-2">
{[
"Debutant complet",
"J'ai deja teste des choses",
"Je genere deja des revenus en ligne",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.experience === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("experience", opt)}
>
{opt}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Temps disponible par jour
</label>
<div className="space-y-2">
{["1-2 heures", "2-4 heures", "4+ heures", "Temps plein"].map(
(opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.time_daily === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("time_daily", opt)}
>
{opt}
</button>
)
)}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Disponibilite pour commencer
</label>
<div className="space-y-2">
{[
"Immediatement",
"Dans 1-2 semaines",
"Dans 1 mois",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.availability === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("availability", opt)}
>
{opt}
</button>
))}
</div>
</div>
<div className="space-y-1.5">
<label className="block text-sm font-medium text-white/80">
Quand souhaites-tu commencer ?
</label>
<div className="space-y-2">
{[
"Cette semaine",
"La semaine prochaine",
"Ce mois-ci",
].map((opt) => (
<button
key={opt}
type="button"
className={`w-full p-3 rounded-xl border text-left text-sm transition-all cursor-pointer ${
formData.start_date === opt
? "border-primary bg-primary/10 text-white"
: "border-dark-border bg-dark-lighter text-white/60 hover:border-primary/30"
}`}
onClick={() => updateField("start_date", opt)}
>
{opt}
</button>
))}
</div>
</div>
</div>
)}
{/* Étape 3 : Motivation */}
{step === 3 && (
<div className="space-y-5">
<h2 className="text-xl font-bold text-white mb-6">
Ta motivation
</h2>
<Textarea
id="motivation"
label="Pourquoi veux-tu rejoindre HookLab ?"
placeholder="Parle-nous de tes objectifs, de ce qui te motive..."
rows={4}
value={formData.motivation}
onChange={(e) => updateField("motivation", e.target.value)}
/>
<Input
id="monthly_goal"
label="Objectif de revenus mensuels"
placeholder="Ex: 1000€/mois"
value={formData.monthly_goal}
onChange={(e) => updateField("monthly_goal", e.target.value)}
/>
<Textarea
id="biggest_fear"
label="Quelle est ta plus grande peur ?"
placeholder="Qu'est-ce qui pourrait t'empecher de reussir ?"
rows={3}
value={formData.biggest_fear}
onChange={(e) => updateField("biggest_fear", e.target.value)}
/>
<Input
id="tiktok_username"
label="Pseudo TikTok (optionnel)"
placeholder="@tonpseudo"
value={formData.tiktok_username}
onChange={(e) => updateField("tiktok_username", e.target.value)}
/>
</div>
)}
{/* Error */}
{error && (
<div className="mt-4 p-3 bg-error/10 border border-error/20 rounded-xl">
<p className="text-error text-sm">{error}</p>
</div>
)}
{/* Navigation */}
<div className="flex items-center justify-between mt-8 pt-6 border-t border-dark-border">
{step > 1 ? (
<Button
variant="ghost"
onClick={() => setStep((step - 1) as Step)}
>
Retour
</Button>
) : (
<Link href="/">
<Button variant="ghost">Annuler</Button>
</Link>
)}
{step < 3 ? (
<Button
onClick={() => setStep((step + 1) as Step)}
disabled={!canGoNext()}
>
Continuer
</Button>
) : (
<Button
onClick={handleSubmit}
loading={loading}
disabled={!canGoNext()}
>
Envoyer ma candidature
</Button>
)}
</div>
</div>
{/* Step indicator text */}
<p className="text-center text-white/30 text-sm mt-4">
Etape {step} sur 3
</p>
</div>
</main>
);
}

View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import Button from "@/components/ui/Button";
export default function MerciPage() {
return (
<main className="min-h-screen flex items-center justify-center px-4">
<div className="text-center max-w-lg">
{/* Success icon */}
<div className="w-20 h-20 gradient-bg rounded-full flex items-center justify-center mx-auto mb-8">
<svg
className="w-10 h-10 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<h1 className="text-3xl md:text-4xl font-bold tracking-[-0.02em] mb-4">
Candidature <span className="gradient-text">envoyee !</span>
</h1>
<p className="text-white/60 text-lg mb-2">
Merci pour ta candidature. Notre equipe va etudier ton profil
attentivement.
</p>
<p className="text-white/40 mb-8">
Tu recevras une reponse par email sous 24 heures. Pense a verifier
tes spams !
</p>
{/* Étapes suivantes */}
<div className="bg-dark-light border border-dark-border rounded-[20px] p-6 mb-8 text-left">
<h2 className="text-white font-semibold mb-4">Prochaines etapes</h2>
<div className="space-y-4">
{[
{
step: "1",
title: "Analyse de ton profil",
desc: "Notre equipe evalue ta candidature",
},
{
step: "2",
title: "Email de confirmation",
desc: "Tu recois un email avec le lien de paiement",
},
{
step: "3",
title: "Acces au programme",
desc: "Tu commences ta formation immediatement",
},
].map((item) => (
<div key={item.step} className="flex items-start gap-3">
<div className="w-7 h-7 gradient-bg rounded-lg flex items-center justify-center shrink-0 text-xs font-bold text-white">
{item.step}
</div>
<div>
<p className="text-white text-sm font-medium">{item.title}</p>
<p className="text-white/40 text-xs">{item.desc}</p>
</div>
</div>
))}
</div>
</div>
<Link href="/">
<Button variant="secondary">Retour a l&apos;accueil</Button>
</Link>
</div>
</main>
);
}

23
app/(marketing)/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import Navbar from "@/components/marketing/Navbar";
import Hero from "@/components/marketing/Hero";
import Testimonials from "@/components/marketing/Testimonials";
import PersonaCards from "@/components/marketing/PersonaCards";
import Method from "@/components/marketing/Method";
import Pricing from "@/components/marketing/Pricing";
import FAQ from "@/components/marketing/FAQ";
import Footer from "@/components/marketing/Footer";
export default function LandingPage() {
return (
<main className="min-h-screen">
<Navbar />
<Hero />
<Testimonials />
<PersonaCards />
<Method />
<Pricing />
<FAQ />
<Footer />
</main>
);
}

View File

@@ -0,0 +1,132 @@
import { NextResponse } from "next/server";
import { createAdminClient } from "@/lib/supabase/server";
import type { CandidatureInsert } from "@/types/database.types";
export async function POST(request: Request) {
try {
const body = await request.json();
// Validation des champs requis
const requiredFields: (keyof CandidatureInsert)[] = [
"email",
"firstname",
"phone",
"persona",
"age",
"experience",
"time_daily",
"availability",
"start_date",
"motivation",
"monthly_goal",
"biggest_fear",
];
for (const field of requiredFields) {
if (!body[field] && body[field] !== 0) {
return NextResponse.json(
{ error: `Le champ "${field}" est requis.` },
{ status: 400 }
);
}
}
// Validation email basique
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(body.email)) {
return NextResponse.json(
{ error: "Adresse email invalide." },
{ status: 400 }
);
}
// Validation age
if (body.age < 18 || body.age > 65) {
return NextResponse.json(
{ error: "L'age doit etre entre 18 et 65 ans." },
{ status: 400 }
);
}
const supabase = createAdminClient();
// Vérifier si une candidature existe déjà avec cet email
const { data: existing } = await supabase
.from("candidatures")
.select("id")
.eq("email", body.email)
.single() as { data: { id: string } | null };
if (existing) {
return NextResponse.json(
{ error: "Une candidature avec cet email existe deja." },
{ status: 409 }
);
}
// Insérer la candidature
const candidature: CandidatureInsert = {
email: body.email,
firstname: body.firstname,
phone: body.phone,
persona: body.persona,
age: body.age,
experience: body.experience,
time_daily: body.time_daily,
availability: body.availability,
start_date: body.start_date,
motivation: body.motivation,
monthly_goal: body.monthly_goal,
biggest_fear: body.biggest_fear,
tiktok_username: body.tiktok_username || null,
};
const { error: insertError } = await supabase
.from("candidatures")
.insert(candidature as never);
if (insertError) {
console.error("Erreur insertion candidature:", insertError);
return NextResponse.json(
{ error: "Erreur lors de l'enregistrement de la candidature." },
{ status: 500 }
);
}
// Envoi email de confirmation (Resend)
if (process.env.RESEND_API_KEY && process.env.RESEND_API_KEY !== "re_your-api-key") {
try {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "HookLab <noreply@hooklab.fr>",
to: body.email,
subject: "Candidature HookLab recue !",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #6D5EF6;">Candidature recue !</h1>
<p>Salut ${body.firstname},</p>
<p>Merci pour ta candidature au programme HookLab !</p>
<p>Notre equipe va etudier ton profil et te repondre sous <strong>24 heures</strong>.</p>
<p>A tres vite,<br/>L'equipe HookLab</p>
</div>
`,
});
} catch (emailError) {
// Log l'erreur mais ne bloque pas la candidature
console.error("Erreur envoi email:", emailError);
}
}
return NextResponse.json(
{ message: "Candidature enregistree avec succes." },
{ status: 201 }
);
} catch {
return NextResponse.json(
{ error: "Erreur serveur. Veuillez reessayer." },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,70 @@
import { NextResponse } from "next/server";
import { createClient } from "@/lib/supabase/server";
import type { Module, UserProgress } from "@/types/database.types";
// GET /api/formations/[moduleId] - Récupérer un module
export async function GET(
_request: Request,
{ params }: { params: Promise<{ moduleId: string }> }
) {
try {
const { moduleId } = await params;
const supabase = await createClient();
// Vérifier l'authentification
const {
data: { user },
} = await supabase.auth.getUser();
if (!user) {
return NextResponse.json(
{ error: "Non authentifie." },
{ status: 401 }
);
}
// Vérifier l'abonnement actif
const { data: profile } = await supabase
.from("profiles")
.select("subscription_status")
.eq("id", user.id)
.single() as { data: { subscription_status: string } | null };
if (!profile || profile.subscription_status !== "active") {
return NextResponse.json(
{ error: "Abonnement inactif." },
{ status: 403 }
);
}
// Récupérer le module
const { data: module, error } = await supabase
.from("modules")
.select("*")
.eq("id", moduleId)
.eq("is_published", true)
.single() as { data: Module | null; error: unknown };
if (error || !module) {
return NextResponse.json(
{ error: "Module non trouve." },
{ status: 404 }
);
}
// Récupérer la progression
const { data: progress } = await supabase
.from("user_progress")
.select("*")
.eq("user_id", user.id)
.eq("module_id", moduleId)
.single() as { data: UserProgress | null };
return NextResponse.json({ module, progress });
} catch {
return NextResponse.json(
{ error: "Erreur serveur." },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,65 @@
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/client";
import { getBaseUrl } from "@/lib/utils";
export async function POST(request: Request) {
try {
const body = await request.json();
const { email, candidatureId } = body;
if (!email) {
return NextResponse.json(
{ error: "Email requis." },
{ status: 400 }
);
}
const baseUrl = getBaseUrl();
// Créer ou récupérer le customer Stripe
const customers = await stripe.customers.list({
email,
limit: 1,
});
let customerId: string;
if (customers.data.length > 0) {
customerId = customers.data[0].id;
} else {
const customer = await stripe.customers.create({
email,
metadata: { candidature_id: candidatureId || "" },
});
customerId = customer.id;
}
// Créer la session Checkout
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: "subscription",
payment_method_types: ["card"],
line_items: [
{
price: process.env.STRIPE_PRICE_ID!,
quantity: 1,
},
],
metadata: {
candidature_id: candidatureId || "",
email,
},
success_url: `${baseUrl}/merci?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/candidature`,
allow_promotion_codes: true,
billing_address_collection: "required",
});
return NextResponse.json({ url: session.url });
} catch (err) {
console.error("Erreur creation session Stripe:", err);
return NextResponse.json(
{ error: "Erreur lors de la creation de la session de paiement." },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,229 @@
import { NextResponse } from "next/server";
import { stripe } from "@/lib/stripe/client";
import { createAdminClient } from "@/lib/supabase/server";
import Stripe from "stripe";
// Désactiver le body parser pour les webhooks Stripe
export const runtime = "nodejs";
export async function POST(request: Request) {
const body = await request.text();
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json(
{ error: "Signature manquante." },
{ status: 400 }
);
}
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("Erreur verification webhook:", err);
return NextResponse.json(
{ error: "Signature invalide." },
{ status: 400 }
);
}
const supabase = createAdminClient();
try {
switch (event.type) {
// Paiement initial réussi
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
const email = session.metadata?.email || session.customer_email;
const customerId = session.customer as string;
if (!email) {
console.error("Email manquant dans la session Stripe");
break;
}
// Générer un mot de passe temporaire
const tempPassword = generatePassword();
// Créer le compte utilisateur Supabase
const { data: authUser, error: authError } =
await supabase.auth.admin.createUser({
email,
password: tempPassword,
email_confirm: true,
user_metadata: {
full_name: email.split("@")[0],
},
});
if (authError) {
// L'utilisateur existe peut-être déjà
console.error("Erreur creation user:", authError);
// Mettre à jour le profil existant si l'utilisateur existe
const { data: existingProfile } = await supabase
.from("profiles")
.select("id")
.eq("email", email)
.single() as { data: { id: string } | null };
if (existingProfile) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
stripe_customer_id: customerId,
subscription_end_date: new Date(
Date.now() + 60 * 24 * 60 * 60 * 1000 // 60 jours
).toISOString(),
} as never)
.eq("id", existingProfile.id);
}
break;
}
// Mettre à jour le profil avec les infos Stripe
if (authUser.user) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
stripe_customer_id: customerId,
subscription_end_date: new Date(
Date.now() + 60 * 24 * 60 * 60 * 1000
).toISOString(),
} as never)
.eq("id", authUser.user.id);
// Log du paiement
await supabase.from("payments").insert({
user_id: authUser.user.id,
stripe_payment_intent_id:
(session.payment_intent as string) || session.id,
amount: session.amount_total || 49000,
currency: session.currency || "eur",
status: "succeeded",
metadata: {
checkout_session_id: session.id,
candidature_id: session.metadata?.candidature_id,
},
} as never);
}
// Envoyer email de bienvenue avec credentials
if (
process.env.RESEND_API_KEY &&
process.env.RESEND_API_KEY !== "re_your-api-key"
) {
try {
const { Resend } = await import("resend");
const resend = new Resend(process.env.RESEND_API_KEY);
await resend.emails.send({
from: "HookLab <noreply@hooklab.fr>",
to: email,
subject: "Bienvenue dans HookLab ! Tes acces sont prets",
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h1 style="color: #6D5EF6;">Bienvenue dans HookLab !</h1>
<p>Ton paiement a ete confirme. Voici tes acces :</p>
<div style="background: #1A1F2E; padding: 20px; border-radius: 12px; margin: 20px 0;">
<p style="color: #fff; margin: 5px 0;"><strong>Email :</strong> ${email}</p>
<p style="color: #fff; margin: 5px 0;"><strong>Mot de passe :</strong> ${tempPassword}</p>
</div>
<p>Connecte-toi sur <a href="${process.env.NEXT_PUBLIC_APP_URL}/login" style="color: #6D5EF6;">hooklab.fr/login</a> pour commencer.</p>
<p><strong>Pense a changer ton mot de passe apres ta premiere connexion !</strong></p>
<p>A tres vite,<br/>L'equipe HookLab</p>
</div>
`,
});
} catch (emailError) {
console.error("Erreur envoi email welcome:", emailError);
}
}
break;
}
// Renouvellement mensuel réussi
case "invoice.paid": {
const invoice = event.data.object as Stripe.Invoice;
const customerId = invoice.customer as string;
// Mettre à jour la date de fin d'abonnement
const { data: profile } = await supabase
.from("profiles")
.select("id")
.eq("stripe_customer_id", customerId)
.single() as { data: { id: string } | null };
if (profile) {
await supabase
.from("profiles")
.update({
subscription_status: "active",
subscription_end_date: new Date(
Date.now() + 30 * 24 * 60 * 60 * 1000
).toISOString(),
} as never)
.eq("id", profile.id);
// Log du paiement
const invoicePI = (invoice as unknown as Record<string, unknown>).payment_intent;
await supabase.from("payments").insert({
user_id: profile.id,
stripe_payment_intent_id:
(invoicePI as string) || invoice.id,
amount: invoice.amount_paid,
currency: invoice.currency,
status: "succeeded",
metadata: { invoice_id: invoice.id },
} as never);
}
break;
}
// Abonnement annulé
case "customer.subscription.deleted": {
const subscription = event.data.object as Stripe.Subscription;
const customerId = subscription.customer as string;
await supabase
.from("profiles")
.update({ subscription_status: "cancelled" } as never)
.eq("stripe_customer_id", customerId);
break;
}
default:
console.log(`Webhook non gere: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (err) {
console.error("Erreur traitement webhook:", err);
return NextResponse.json(
{ error: "Erreur traitement webhook." },
{ status: 500 }
);
}
}
// Générateur de mot de passe temporaire
function generatePassword(): string {
const chars =
"ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789!@#$%";
let password = "";
for (let i = 0; i < 12; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
}

92
app/globals.css Normal file
View File

@@ -0,0 +1,92 @@
@import "tailwindcss";
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');
@theme inline {
--color-primary: #6D5EF6;
--color-primary-hover: #5B4FDB;
--color-primary-50: #F3F1FF;
--color-primary-100: #E9E5FF;
--color-primary-light: #9D8FF9;
--color-dark: #0B0F19;
--color-dark-light: #1A1F2E;
--color-dark-lighter: #252A3A;
--color-dark-border: #2A2F3F;
--color-success: #10B981;
--color-warning: #F59E0B;
--color-error: #EF4444;
--font-sans: "Inter", sans-serif;
--radius-card: 20px;
--radius-button: 12px;
}
body {
background: var(--color-dark);
color: #ffffff;
font-family: var(--font-sans);
}
/* Scrollbar personnalisée */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-dark);
}
::-webkit-scrollbar-thumb {
background: var(--color-dark-lighter);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-primary);
}
/* Animation hover cards */
.card-hover {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card-hover:hover {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(109, 94, 246, 0.15);
}
/* Gradient text */
.gradient-text {
background: linear-gradient(135deg, #6D5EF6, #9D8FF9);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Gradient background */
.gradient-bg {
background: linear-gradient(135deg, #6D5EF6, #9D8FF9);
}
/* Glass effect */
.glass {
background: rgba(26, 31, 46, 0.8);
backdrop-filter: blur(20px);
border: 1px solid rgba(109, 94, 246, 0.1);
}
/* Pulse animation pour CTA */
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 20px rgba(109, 94, 246, 0.3);
}
50% {
box-shadow: 0 0 40px rgba(109, 94, 246, 0.6);
}
}
.pulse-glow {
animation: pulse-glow 2s ease-in-out infinite;
}

33
app/layout.tsx Normal file
View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "HookLab | Programme coaching TikTok Shop 8 semaines",
description:
"Rejoins HookLab et lance ton business TikTok Shop en 8 semaines. Programme de coaching complet pour créateurs affiliés.",
keywords: [
"TikTok Shop",
"coaching",
"affiliation",
"créateur",
"formation",
],
openGraph: {
title: "HookLab | Programme coaching TikTok Shop",
description:
"Lance ton business TikTok Shop en 8 semaines avec notre programme de coaching.",
type: "website",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body className="antialiased">{children}</body>
</html>
);
}

View File

@@ -0,0 +1,146 @@
import Link from "next/link";
import Card from "@/components/ui/Card";
import type { Module, UserProgress } from "@/types/database.types";
interface ModuleCardProps {
module: Module;
progress?: UserProgress;
}
const contentTypeIcons: Record<string, React.ReactNode> = {
video: (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
pdf: (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 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>
),
text: (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M4 6h16M4 12h16M4 18h7"
/>
</svg>
),
quiz: (
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
),
};
export default function ModuleCard({ module, progress }: ModuleCardProps) {
const isCompleted = progress?.completed;
return (
<Link href={`/formations/${module.id}`}>
<Card hover className="relative overflow-hidden group">
{/* Status indicator */}
{isCompleted && (
<div className="absolute top-4 right-4">
<div className="w-6 h-6 rounded-full bg-success flex items-center justify-center">
<svg
className="w-3.5 h-3.5 text-white"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
</div>
)}
{/* Content type + Duration */}
<div className="flex items-center gap-3 mb-3">
<span
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium ${
isCompleted
? "bg-success/10 text-success"
: "bg-primary/10 text-primary"
}`}
>
{module.content_type && contentTypeIcons[module.content_type]}
{module.content_type?.toUpperCase() || "CONTENU"}
</span>
{module.duration_minutes && (
<span className="text-white/30 text-xs">
{module.duration_minutes} min
</span>
)}
</div>
{/* Title */}
<h3 className="text-white font-semibold mb-2 group-hover:text-primary transition-colors">
{module.title}
</h3>
{/* Description */}
{module.description && (
<p className="text-white/50 text-sm line-clamp-2">
{module.description}
</p>
)}
{/* Week badge */}
<div className="mt-4 pt-3 border-t border-dark-border">
<span className="text-white/30 text-xs">
Semaine {module.week_number}
</span>
</div>
</Card>
</Link>
);
}

View File

@@ -0,0 +1,36 @@
interface ProgressBarProps {
value: number; // 0-100
label?: string;
showPercentage?: boolean;
}
export default function ProgressBar({
value,
label,
showPercentage = true,
}: ProgressBarProps) {
const clampedValue = Math.min(100, Math.max(0, value));
return (
<div className="space-y-2">
{(label || showPercentage) && (
<div className="flex items-center justify-between">
{label && (
<span className="text-white/60 text-sm">{label}</span>
)}
{showPercentage && (
<span className="text-white font-medium text-sm">
{Math.round(clampedValue)}%
</span>
)}
</div>
)}
<div className="h-2 bg-dark-lighter rounded-full overflow-hidden">
<div
className="h-full gradient-bg rounded-full transition-all duration-500 ease-out"
style={{ width: `${clampedValue}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,156 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
import { createClient } from "@/lib/supabase/client";
import { cn } from "@/lib/utils";
import type { Profile } from "@/types/database.types";
interface SidebarProps {
user: Profile;
}
const navItems = [
{
label: "Dashboard",
href: "/dashboard",
icon: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"
/>
</svg>
),
},
{
label: "Formations",
href: "/formations",
icon: (
<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 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
},
{
label: "Profil",
href: "/profil",
icon: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
},
];
export default function Sidebar({ user }: SidebarProps) {
const pathname = usePathname();
const router = useRouter();
const handleLogout = async () => {
const supabase = createClient();
await supabase.auth.signOut();
router.push("/login");
router.refresh();
};
return (
<aside className="w-64 min-h-screen bg-dark-light border-r border-dark-border p-6 flex flex-col">
{/* Logo */}
<Link href="/dashboard" className="flex items-center gap-2 mb-10">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">H</span>
</div>
<span className="text-xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
{/* Navigation */}
<nav className="flex-1 space-y-1">
{navItems.map((item) => {
const isActive =
pathname === item.href ||
(item.href !== "/dashboard" && pathname.startsWith(item.href));
return (
<Link
key={item.href}
href={item.href}
className={cn(
"flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-medium transition-all",
isActive
? "bg-primary/10 text-primary"
: "text-white/50 hover:text-white hover:bg-white/5"
)}
>
{item.icon}
{item.label}
</Link>
);
})}
</nav>
{/* User info + Logout */}
<div className="border-t border-dark-border pt-4 mt-4">
<div className="flex items-center gap-3 mb-4">
<div className="w-9 h-9 gradient-bg rounded-full flex items-center justify-center text-sm font-bold text-white">
{(user.full_name || user.email)[0].toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-white text-sm font-medium truncate">
{user.full_name || "Utilisateur"}
</p>
<p className="text-white/40 text-xs truncate">{user.email}</p>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 text-white/40 hover:text-error text-sm transition-colors cursor-pointer w-full"
>
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"
/>
</svg>
Deconnexion
</button>
</div>
</aside>
);
}

View File

@@ -0,0 +1,110 @@
"use client";
import { useState } from "react";
const faqs = [
{
question: "Ai-je besoin d'experience sur TikTok ?",
answer:
"Non, aucune experience n'est requise. Notre programme part de zero et t'accompagne etape par etape. Beaucoup de nos eleves n'avaient jamais poste de video avant de commencer.",
},
{
question: "Combien de temps dois-je consacrer par jour ?",
answer:
"Nous recommandons un minimum de 2 heures par jour pour des resultats optimaux. Le programme est concu pour etre flexible et s'adapter a ton emploi du temps, que tu sois etudiant ou parent.",
},
{
question: "Quand vais-je voir mes premiers resultats ?",
answer:
"La plupart de nos eleves generent leurs premieres commissions dans les 2 a 4 premieres semaines. Les resultats varient selon ton implication et le temps consacre.",
},
{
question: "Dois-je investir de l'argent en plus du programme ?",
answer:
"Non. L'affiliation TikTok Shop ne necessite aucun stock ni investissement supplementaire. Tu gagnes des commissions sur les ventes generees par tes videos.",
},
{
question: "Le programme est-il adapte a tous les ages ?",
answer:
"Oui, nos eleves ont entre 18 et 55 ans. Le programme propose deux parcours adaptes : un pour les jeunes (18-25 ans) et un pour les parents/reconversion (25-45 ans).",
},
{
question: "Comment se deroule le coaching ?",
answer:
"Le coaching comprend des modules video hebdomadaires, des appels de groupe chaque semaine, un support WhatsApp illimite, et l'acces a une communaute privee d'entrepreneurs.",
},
{
question: "Puis-je payer en plusieurs fois ?",
answer:
"Oui, le paiement se fait en 2 mensualites de 490€. Le premier paiement donne acces immediat au programme, le second est preleve automatiquement le mois suivant.",
},
{
question: "Y a-t-il une garantie de remboursement ?",
answer:
"Oui, nous offrons une garantie satisfait ou rembourse de 14 jours. Si le programme ne te convient pas, tu es rembourse integralement, sans condition.",
},
];
export default function FAQ() {
const [openIndex, setOpenIndex] = useState<number | null>(null);
return (
<section id="faq" className="py-20 md:py-32 bg-dark-light/30">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center mb-16">
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
FAQ
</span>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
Questions <span className="gradient-text">frequentes</span>
</h2>
<p className="text-white/60 text-lg">
Tout ce que tu dois savoir avant de te lancer.
</p>
</div>
{/* Accordion */}
<div className="space-y-3">
{faqs.map((faq, i) => (
<div
key={i}
className="bg-dark-light border border-dark-border rounded-2xl overflow-hidden transition-all duration-300"
>
<button
className="w-full px-6 py-5 flex items-center justify-between text-left cursor-pointer"
onClick={() => setOpenIndex(openIndex === i ? null : i)}
>
<span className="text-white font-medium pr-4">
{faq.question}
</span>
<svg
className={`w-5 h-5 text-primary shrink-0 transition-transform duration-300 ${
openIndex === i ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{openIndex === i && (
<div className="px-6 pb-5">
<p className="text-white/60 text-sm leading-relaxed">
{faq.answer}
</p>
</div>
)}
</div>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,129 @@
import Link from "next/link";
export default function Footer() {
return (
<footer className="border-t border-dark-border py-12 md:py-16">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 md:grid-cols-4 gap-8 md:gap-12">
{/* Brand */}
<div className="md:col-span-2">
<Link href="/" className="flex items-center gap-2 mb-4">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">H</span>
</div>
<span className="text-xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
<p className="text-white/40 text-sm max-w-xs leading-relaxed">
Le programme de coaching TikTok Shop pour lancer ton business
d&apos;affiliation en 8 semaines.
</p>
</div>
{/* Links */}
<div>
<h4 className="text-white font-semibold text-sm mb-4">
Programme
</h4>
<ul className="space-y-2.5">
<li>
<a
href="#methode"
className="text-white/40 hover:text-white text-sm transition-colors"
>
La methode
</a>
</li>
<li>
<a
href="#temoignages"
className="text-white/40 hover:text-white text-sm transition-colors"
>
Temoignages
</a>
</li>
<li>
<a
href="#tarif"
className="text-white/40 hover:text-white text-sm transition-colors"
>
Tarif
</a>
</li>
<li>
<a
href="#faq"
className="text-white/40 hover:text-white text-sm transition-colors"
>
FAQ
</a>
</li>
</ul>
</div>
<div>
<h4 className="text-white font-semibold text-sm mb-4">Legal</h4>
<ul className="space-y-2.5">
<li>
<Link
href="/mentions-legales"
className="text-white/40 hover:text-white text-sm transition-colors"
>
Mentions legales
</Link>
</li>
<li>
<Link
href="/cgv"
className="text-white/40 hover:text-white text-sm transition-colors"
>
CGV
</Link>
</li>
<li>
<Link
href="/confidentialite"
className="text-white/40 hover:text-white text-sm transition-colors"
>
Confidentialite
</Link>
</li>
</ul>
</div>
</div>
{/* Bottom */}
<div className="border-t border-dark-border mt-12 pt-8 flex flex-col md:flex-row items-center justify-between gap-4">
<p className="text-white/30 text-sm">
&copy; {new Date().getFullYear()} HookLab. Tous droits reserves.
</p>
<div className="flex items-center gap-4">
<a
href="https://tiktok.com"
target="_blank"
rel="noopener noreferrer"
className="text-white/30 hover:text-primary transition-colors"
aria-label="TikTok"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M19.59 6.69a4.83 4.83 0 01-3.77-4.25V2h-3.45v13.67a2.89 2.89 0 01-2.88 2.5 2.89 2.89 0 01-2.89-2.89 2.89 2.89 0 012.89-2.89c.28 0 .54.04.79.11V9.01a6.27 6.27 0 00-.79-.05 6.34 6.34 0 00-6.34 6.34 6.34 6.34 0 006.34 6.34 6.34 6.34 0 006.34-6.34V8.92a8.2 8.2 0 004.76 1.52V7a4.84 4.84 0 01-1-.31z" />
</svg>
</a>
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
className="text-white/30 hover:text-primary transition-colors"
aria-label="Instagram"
>
<svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 100 12.324 6.162 6.162 0 000-12.324zM12 16a4 4 0 110-8 4 4 0 010 8zm6.406-11.845a1.44 1.44 0 100 2.881 1.44 1.44 0 000-2.881z" />
</svg>
</a>
</div>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,91 @@
import Link from "next/link";
import Button from "@/components/ui/Button";
export default function Hero() {
return (
<section className="relative pt-32 pb-20 md:pt-40 md:pb-32 overflow-hidden">
{/* Gradient background effect */}
<div className="absolute inset-0 overflow-hidden">
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-primary/20 rounded-full blur-[120px]" />
</div>
<div className="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="text-center max-w-4xl mx-auto">
{/* Badges */}
<div className="flex flex-wrap justify-center gap-3 mb-8">
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium">
<span className="w-1.5 h-1.5 bg-success rounded-full animate-pulse" />
Places limitees - Promo en cours
</span>
<span className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-dark-light border border-dark-border rounded-full text-white/60 text-xs font-medium">
Programme 8 semaines
</span>
</div>
{/* Titre principal */}
<h1 className="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-bold tracking-[-0.02em] leading-[1.1] mb-6">
Lance ton business{" "}
<span className="gradient-text">TikTok Shop</span> en 8 semaines
</h1>
{/* Sous-titre */}
<p className="text-lg md:text-xl text-white/60 max-w-2xl mx-auto mb-10 leading-relaxed">
Le programme de coaching complet pour devenir createur affilie
TikTok Shop et generer tes premiers revenus en ligne.
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-12">
<Link href="/candidature">
<Button size="lg" className="pulse-glow text-lg px-10">
Candidater maintenant
</Button>
</Link>
<a href="#methode">
<Button variant="secondary" size="lg">
Decouvrir la methode
</Button>
</a>
</div>
{/* Social proof */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-6 md:gap-10">
<div className="flex items-center gap-2">
{/* Avatars empilés */}
<div className="flex -space-x-2">
{[1, 2, 3, 4].map((i) => (
<div
key={i}
className="w-8 h-8 rounded-full gradient-bg border-2 border-dark flex items-center justify-center text-xs font-bold text-white"
>
{String.fromCharCode(64 + i)}
</div>
))}
</div>
<span className="text-sm text-white/60">
<span className="text-white font-semibold">+120</span> eleves
formes
</span>
</div>
<div className="flex items-center gap-1.5">
{[1, 2, 3, 4, 5].map((i) => (
<svg
key={i}
className="w-4 h-4 text-warning"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
<span className="text-sm text-white/60 ml-1">
<span className="text-white font-semibold">4.9/5</span> de
satisfaction
</span>
</div>
</div>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,123 @@
import Card from "@/components/ui/Card";
const steps = [
{
number: "01",
title: "Apprends les bases",
description:
"Maitrise les fondamentaux de TikTok Shop, l'algorithme, et les techniques de creation de contenu qui convertissent.",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"
/>
</svg>
),
weeks: "Semaines 1-2",
},
{
number: "02",
title: "Lance ton activite",
description:
"Configure ton shop, selectionne tes produits gagnants, et publie tes premieres videos avec notre methode eprouvee.",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13 10V3L4 14h7v7l9-11h-7z"
/>
</svg>
),
weeks: "Semaines 3-5",
},
{
number: "03",
title: "Scale tes revenus",
description:
"Optimise tes performances, automatise tes process, et developpe une strategie de contenu rentable sur le long terme.",
icon: (
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6"
/>
</svg>
),
weeks: "Semaines 6-8",
},
];
export default function Method() {
return (
<section id="methode" className="py-20 md:py-32">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center max-w-2xl mx-auto mb-16">
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
La methode
</span>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
3 etapes vers tes{" "}
<span className="gradient-text">premiers revenus</span>
</h2>
<p className="text-white/60 text-lg">
Un programme structure semaine par semaine pour te guider vers la
rentabilite.
</p>
</div>
{/* Steps */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{steps.map((step, i) => (
<Card key={i} hover className="relative">
{/* Step number */}
<div className="absolute top-6 right-6 text-5xl font-bold text-white/5">
{step.number}
</div>
{/* Icon */}
<div className="w-12 h-12 gradient-bg rounded-2xl flex items-center justify-center text-white mb-5">
{step.icon}
</div>
{/* Weeks badge */}
<span className="inline-block px-2.5 py-1 bg-primary/10 rounded-lg text-primary text-xs font-medium mb-3">
{step.weeks}
</span>
{/* Content */}
<h3 className="text-xl font-bold text-white mb-3">
{step.title}
</h3>
<p className="text-white/60 text-sm leading-relaxed">
{step.description}
</p>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,145 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Button from "@/components/ui/Button";
export default function Navbar() {
const [isOpen, setIsOpen] = useState(false);
return (
<nav className="fixed top-0 left-0 right-0 z-50 glass">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16 md:h-20">
{/* Logo */}
<Link href="/" className="flex items-center gap-2">
<div className="w-8 h-8 gradient-bg rounded-lg flex items-center justify-center">
<span className="text-white font-bold text-sm">H</span>
</div>
<span className="text-xl font-bold text-white">
Hook<span className="gradient-text">Lab</span>
</span>
</Link>
{/* Navigation desktop */}
<div className="hidden md:flex items-center gap-8">
<a
href="#methode"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
>
Methode
</a>
<a
href="#temoignages"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
>
Resultats
</a>
<a
href="#tarif"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
>
Tarif
</a>
<a
href="#faq"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
>
FAQ
</a>
</div>
{/* CTA desktop */}
<div className="hidden md:flex items-center gap-4">
<Link href="/login">
<Button variant="ghost" size="sm">
Connexion
</Button>
</Link>
<Link href="/candidature">
<Button size="sm">Candidater</Button>
</Link>
</div>
{/* Hamburger mobile */}
<button
className="md:hidden text-white p-2"
onClick={() => setIsOpen(!isOpen)}
aria-label="Menu"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
{isOpen ? (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
) : (
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
)}
</svg>
</button>
</div>
{/* Menu mobile */}
{isOpen && (
<div className="md:hidden pb-6 border-t border-dark-border pt-4">
<div className="flex flex-col gap-4">
<a
href="#methode"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
onClick={() => setIsOpen(false)}
>
Methode
</a>
<a
href="#temoignages"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
onClick={() => setIsOpen(false)}
>
Resultats
</a>
<a
href="#tarif"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
onClick={() => setIsOpen(false)}
>
Tarif
</a>
<a
href="#faq"
className="text-white/70 hover:text-white transition-colors text-sm font-medium"
onClick={() => setIsOpen(false)}
>
FAQ
</a>
<div className="flex flex-col gap-2 pt-2">
<Link href="/login">
<Button variant="ghost" size="sm" className="w-full">
Connexion
</Button>
</Link>
<Link href="/candidature">
<Button size="sm" className="w-full">
Candidater
</Button>
</Link>
</div>
</div>
</div>
)}
</div>
</nav>
);
}

View File

@@ -0,0 +1,100 @@
import Card from "@/components/ui/Card";
const personas = [
{
id: "jeune",
emoji: "🎓",
title: "Etudiant / Jeune actif",
subtitle: "18-25 ans",
description:
"Tu veux generer tes premiers revenus en ligne tout en etudiant ou en debut de carriere. TikTok Shop est le levier parfait.",
benefits: [
"Flexibilite totale, travaille quand tu veux",
"Pas besoin de stock ni d'investissement",
"Competences marketing valorisables sur ton CV",
"Communaute de jeunes entrepreneurs motives",
],
},
{
id: "parent",
emoji: "👨‍👩‍👧",
title: "Parent / Reconversion",
subtitle: "25-45 ans",
description:
"Tu cherches un complement de revenus ou une reconversion flexible depuis chez toi. TikTok Shop s'adapte a ton emploi du temps.",
benefits: [
"2h par jour suffisent pour demarrer",
"Travaille depuis chez toi, a ton rythme",
"Revenus complementaires des le premier mois",
"Accompagnement personnalise et bienveillant",
],
},
];
export default function PersonaCards() {
return (
<section className="py-20 md:py-32 bg-dark-light/30">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center max-w-2xl mx-auto mb-16">
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
Pour qui ?
</span>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
Un programme adapte a{" "}
<span className="gradient-text">ton profil</span>
</h2>
<p className="text-white/60 text-lg">
Que tu sois etudiant ou parent, notre methode s&apos;adapte a toi.
</p>
</div>
{/* Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 max-w-4xl mx-auto">
{personas.map((p) => (
<Card key={p.id} hover className="relative overflow-hidden">
{/* Gradient accent top */}
<div className="absolute top-0 left-0 right-0 h-1 gradient-bg" />
<div className="pt-2">
{/* Emoji + Title */}
<div className="text-4xl mb-4">{p.emoji}</div>
<h3 className="text-xl font-bold text-white mb-1">
{p.title}
</h3>
<p className="text-primary text-sm font-medium mb-3">
{p.subtitle}
</p>
<p className="text-white/60 text-sm mb-6 leading-relaxed">
{p.description}
</p>
{/* Benefits */}
<ul className="space-y-3">
{p.benefits.map((b, i) => (
<li key={i} className="flex items-start gap-3">
<svg
className="w-5 h-5 text-success mt-0.5 shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M5 13l4 4L19 7"
/>
</svg>
<span className="text-white/70 text-sm">{b}</span>
</li>
))}
</ul>
</div>
</Card>
))}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,104 @@
import Link from "next/link";
import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card";
const features = [
"8 semaines de coaching intensif",
"Acces a tous les modules video",
"Templates et scripts de contenu",
"Appels de groupe hebdomadaires",
"Support WhatsApp illimite",
"Communaute privee d'entrepreneurs",
"Mises a jour a vie du contenu",
"Certification HookLab",
];
export default function Pricing() {
return (
<section id="tarif" className="py-20 md:py-32">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center max-w-2xl mx-auto mb-16">
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
Tarif
</span>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
Investis dans ton{" "}
<span className="gradient-text">futur business</span>
</h2>
<p className="text-white/60 text-lg">
Un seul programme, tout inclus. Paiement en 2 fois possible.
</p>
</div>
{/* Pricing card */}
<div className="max-w-lg mx-auto">
<Card className="relative overflow-hidden border-primary/30">
{/* Popular badge */}
<div className="absolute top-0 left-0 right-0 gradient-bg py-2 text-center">
<span className="text-white text-sm font-semibold">
Offre de lancement - Places limitees
</span>
</div>
<div className="pt-12">
{/* Price */}
<div className="text-center mb-8">
<div className="flex items-baseline justify-center gap-1">
<span className="text-5xl md:text-6xl font-bold text-white">
490
</span>
<span className="text-white/40 text-lg">/mois</span>
</div>
<p className="text-white/40 mt-2">
x2 mois (980 total) - Paiement securise via Stripe
</p>
</div>
{/* Divider */}
<div className="border-t border-dark-border my-6" />
{/* Features */}
<ul className="space-y-4 mb-8">
{features.map((f, i) => (
<li key={i} className="flex items-center gap-3">
<div className="w-5 h-5 rounded-full bg-success/10 flex items-center justify-center shrink-0">
<svg
className="w-3 h-3 text-success"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={3}
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<span className="text-white/80 text-sm">{f}</span>
</li>
))}
</ul>
{/* CTA */}
<Link href="/candidature">
<Button size="lg" className="w-full pulse-glow">
Rejoindre HookLab
</Button>
</Link>
{/* Disclaimer */}
<p className="text-center text-white/30 text-xs mt-4">
Candidature soumise a validation. Reponse sous 24h.
<br />
Satisfait ou rembourse pendant 14 jours.
</p>
</div>
</Card>
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,91 @@
import Card from "@/components/ui/Card";
const testimonials = [
{
name: "Sarah M.",
role: "Etudiante, 22 ans",
content:
"En 4 semaines, j'ai genere mes premiers 800€ sur TikTok Shop. Le programme m'a donne une methode claire et un accompagnement top.",
revenue: "2 400€/mois",
avatar: "S",
},
{
name: "Thomas D.",
role: "Ex-salarie, 34 ans",
content:
"J'hesitais a me lancer, mais le coaching m'a permis de structurer mon activite. Aujourd'hui je vis de TikTok Shop a plein temps.",
revenue: "4 200€/mois",
avatar: "T",
},
{
name: "Amina K.",
role: "Mere au foyer, 29 ans",
content:
"Je cherchais un complement de revenus flexible. Grace a HookLab, je gagne un SMIC supplementaire en travaillant 2h par jour.",
revenue: "1 600€/mois",
avatar: "A",
},
];
export default function Testimonials() {
return (
<section id="temoignages" className="py-20 md:py-32">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* Header */}
<div className="text-center max-w-2xl mx-auto mb-16">
<span className="inline-block px-3 py-1.5 bg-primary/10 border border-primary/20 rounded-full text-primary text-xs font-medium mb-4">
Temoignages
</span>
<h2 className="text-3xl md:text-4xl lg:text-5xl font-bold tracking-[-0.02em] mb-4">
Ils ont <span className="gradient-text">transforme</span> leur vie
</h2>
<p className="text-white/60 text-lg">
Decouvre les resultats de nos eleves apres le programme.
</p>
</div>
{/* Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{testimonials.map((t, i) => (
<Card key={i} hover>
{/* Stars */}
<div className="flex gap-1 mb-4">
{[1, 2, 3, 4, 5].map((s) => (
<svg
key={s}
className="w-4 h-4 text-warning"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
))}
</div>
{/* Content */}
<p className="text-white/80 mb-6 leading-relaxed">
&ldquo;{t.content}&rdquo;
</p>
{/* Revenue badge */}
<div className="inline-flex items-center px-3 py-1.5 bg-success/10 border border-success/20 rounded-full text-success text-sm font-medium mb-6">
{t.revenue}
</div>
{/* Author */}
<div className="flex items-center gap-3 pt-4 border-t border-dark-border">
<div className="w-10 h-10 rounded-full gradient-bg flex items-center justify-center text-sm font-bold text-white">
{t.avatar}
</div>
<div>
<p className="text-white font-medium text-sm">{t.name}</p>
<p className="text-white/40 text-xs">{t.role}</p>
</div>
</div>
</Card>
))}
</div>
</div>
</section>
);
}

85
components/ui/Button.tsx Normal file
View File

@@ -0,0 +1,85 @@
"use client";
import { cn } from "@/lib/utils";
import { ButtonHTMLAttributes, forwardRef } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
loading?: boolean;
}
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
className,
variant = "primary",
size = "md",
loading = false,
disabled,
children,
...props
},
ref
) => {
const baseStyles =
"inline-flex items-center justify-center font-semibold transition-all duration-300 rounded-[12px] cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed";
const variants = {
primary:
"gradient-bg text-white hover:opacity-90 hover:translate-y-[-2px] hover:shadow-lg",
secondary:
"bg-dark-light text-white border border-dark-border hover:border-primary/50 hover:translate-y-[-2px]",
outline:
"bg-transparent text-primary border-2 border-primary hover:bg-primary hover:text-white",
ghost:
"bg-transparent text-white/70 hover:text-white hover:bg-white/5",
};
const sizes = {
sm: "px-4 py-2 text-sm",
md: "px-6 py-3 text-base",
lg: "px-8 py-4 text-lg",
};
return (
<button
ref={ref}
className={cn(baseStyles, variants[variant], sizes[size], className)}
disabled={disabled || loading}
{...props}
>
{loading ? (
<span className="flex items-center gap-2">
<svg
className="animate-spin h-4 w-4"
viewBox="0 0 24 24"
fill="none"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
/>
</svg>
Chargement...
</span>
) : (
children
)}
</button>
);
}
);
Button.displayName = "Button";
export default Button;

32
components/ui/Card.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { cn } from "@/lib/utils";
import { HTMLAttributes, forwardRef } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
hover?: boolean;
glass?: boolean;
}
const Card = forwardRef<HTMLDivElement, CardProps>(
({ className, hover = false, glass = false, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(
"rounded-[20px] p-6",
glass
? "glass"
: "bg-dark-light border border-dark-border",
hover && "card-hover cursor-pointer",
className
)}
{...props}
>
{children}
</div>
);
}
);
Card.displayName = "Card";
export default Card;

77
components/ui/Input.tsx Normal file
View File

@@ -0,0 +1,77 @@
"use client";
import { cn } from "@/lib/utils";
import { InputHTMLAttributes, TextareaHTMLAttributes, forwardRef } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="space-y-1.5">
{label && (
<label
htmlFor={id}
className="block text-sm font-medium text-white/80"
>
{label}
</label>
)}
<input
ref={ref}
id={id}
className={cn(
"w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-[12px] text-white placeholder:text-white/30 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors",
error && "border-error focus:border-error focus:ring-error",
className
)}
{...props}
/>
{error && <p className="text-sm text-error">{error}</p>}
</div>
);
}
);
Input.displayName = "Input";
// Textarea séparé
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
error?: string;
}
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, label, error, id, ...props }, ref) => {
return (
<div className="space-y-1.5">
{label && (
<label
htmlFor={id}
className="block text-sm font-medium text-white/80"
>
{label}
</label>
)}
<textarea
ref={ref}
id={id}
className={cn(
"w-full px-4 py-3 bg-dark-lighter border border-dark-border rounded-[12px] text-white placeholder:text-white/30 focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-colors resize-none",
error && "border-error focus:border-error focus:ring-error",
className
)}
{...props}
/>
{error && <p className="text-sm text-error">{error}</p>}
</div>
);
}
);
Textarea.displayName = "Textarea";
export { Input as default, Textarea };

18
eslint.config.mjs Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

6
lib/stripe/client.ts Normal file
View File

@@ -0,0 +1,6 @@
import Stripe from "stripe";
// Client Stripe côté serveur
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
typescript: true,
});

9
lib/supabase/client.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/types/database.types";
// Client Supabase côté navigateur (composants client)
export const createClient = () =>
createBrowserClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
);

View File

@@ -0,0 +1,59 @@
import { createServerClient } from "@supabase/ssr";
import { NextResponse, type NextRequest } from "next/server";
// Middleware Supabase pour refresh des tokens auth
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value }) =>
request.cookies.set(name, value)
);
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) =>
supabaseResponse.cookies.set(name, value, options)
);
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
// Rediriger vers login si pas connecté et route protégée
if (
!user &&
request.nextUrl.pathname.startsWith("/dashboard")
) {
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
// Rediriger vers dashboard si déjà connecté et sur login/register
if (
user &&
(request.nextUrl.pathname === "/login" ||
request.nextUrl.pathname === "/register")
) {
const url = request.nextUrl.clone();
url.pathname = "/dashboard";
return NextResponse.redirect(url);
}
return supabaseResponse;
}

45
lib/supabase/server.ts Normal file
View File

@@ -0,0 +1,45 @@
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import type { Database } from "@/types/database.types";
// Client Supabase côté serveur (Server Components, Route Handlers)
export const createClient = async () => {
const cookieStore = await cookies();
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
);
} catch {
// Ignore en Server Component (lecture seule)
}
},
},
}
);
};
// Client admin avec service role (webhooks, opérations admin)
export const createAdminClient = () => {
return createServerClient<Database>(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{
cookies: {
getAll() {
return [];
},
setAll() {},
},
}
);
};

28
lib/utils.ts Normal file
View File

@@ -0,0 +1,28 @@
import { type ClassValue, clsx } from "clsx";
// Utilitaire pour combiner les classes CSS (compatible Tailwind)
export function cn(...inputs: ClassValue[]) {
return inputs.filter(Boolean).join(" ");
}
// Formater un prix en euros
export function formatPrice(amount: number): string {
return new Intl.NumberFormat("fr-FR", {
style: "currency",
currency: "EUR",
}).format(amount / 100);
}
// Valider un email
export function isValidEmail(email: string): boolean {
const re = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return re.test(email);
}
// URL de base de l'application
export function getBaseUrl(): string {
if (process.env.NEXT_PUBLIC_APP_URL) {
return process.env.NEXT_PUBLIC_APP_URL;
}
return "http://localhost:3000";
}

18
middleware.ts Normal file
View File

@@ -0,0 +1,18 @@
import { updateSession } from "@/lib/supabase/middleware";
import type { NextRequest } from "next/server";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
// Routes protégées
"/dashboard/:path*",
"/formations/:path*",
"/profil/:path*",
// Routes auth (redirection si déjà connecté)
"/login",
"/register",
],
};

7
next.config.ts Normal file
View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

7107
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "hooklab",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@stripe/stripe-js": "^8.7.0",
"@supabase/ssr": "^0.8.0",
"@supabase/supabase-js": "^2.95.3",
"clsx": "^2.1.1",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"resend": "^6.9.1",
"stripe": "^20.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"tailwindcss": "^4",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
public/file.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,133 @@
-- HookLab - Schéma initial de la base de données
-- À exécuter dans Supabase SQL Editor
-- Table profiles (extension de auth.users)
CREATE TABLE public.profiles (
id UUID REFERENCES auth.users PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
full_name TEXT,
persona TEXT CHECK (persona IN ('jeune', 'parent')),
stripe_customer_id TEXT UNIQUE,
subscription_status TEXT DEFAULT 'inactive' CHECK (subscription_status IN ('inactive', 'active', 'cancelled', 'paused')),
subscription_end_date TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Table candidatures
CREATE TABLE public.candidatures (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
email TEXT NOT NULL,
firstname TEXT NOT NULL,
phone TEXT NOT NULL,
persona TEXT NOT NULL,
age INTEGER NOT NULL,
experience TEXT NOT NULL,
time_daily TEXT NOT NULL,
availability TEXT NOT NULL,
start_date TEXT NOT NULL,
motivation TEXT NOT NULL,
monthly_goal TEXT NOT NULL,
biggest_fear TEXT NOT NULL,
tiktok_username TEXT,
status TEXT DEFAULT 'pending' CHECK (status IN ('pending', 'approved', 'rejected')),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Table modules formations
CREATE TABLE public.modules (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
title TEXT NOT NULL,
description TEXT,
week_number INTEGER NOT NULL,
order_index INTEGER NOT NULL,
content_type TEXT CHECK (content_type IN ('video', 'pdf', 'text', 'quiz')),
content_url TEXT,
duration_minutes INTEGER,
is_published BOOLEAN DEFAULT false,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Table progression élèves
CREATE TABLE public.user_progress (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
module_id UUID REFERENCES public.modules(id) ON DELETE CASCADE,
completed BOOLEAN DEFAULT false,
completed_at TIMESTAMPTZ,
notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, module_id)
);
-- Table paiements (log Stripe)
CREATE TABLE public.payments (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.profiles(id) ON DELETE CASCADE,
stripe_payment_intent_id TEXT UNIQUE NOT NULL,
amount INTEGER NOT NULL,
currency TEXT DEFAULT 'eur',
status TEXT NOT NULL,
metadata JSONB,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Row Level Security
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.user_progress ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.modules ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.payments ENABLE ROW LEVEL SECURITY;
-- Policies profiles
CREATE POLICY "Users can view own profile" ON public.profiles
FOR SELECT USING (auth.uid() = id);
CREATE POLICY "Users can update own profile" ON public.profiles
FOR UPDATE USING (auth.uid() = id);
-- Policies user_progress
CREATE POLICY "Users can view own progress" ON public.user_progress
FOR SELECT USING (auth.uid() = user_id);
CREATE POLICY "Users can insert own progress" ON public.user_progress
FOR INSERT WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can update own progress" ON public.user_progress
FOR UPDATE USING (auth.uid() = user_id);
-- Policies modules (lecture publique pour les modules publiés)
CREATE POLICY "Anyone can view published modules" ON public.modules
FOR SELECT USING (is_published = true);
-- Policies payments
CREATE POLICY "Users can view own payments" ON public.payments
FOR SELECT USING (auth.uid() = user_id);
-- Fonction trigger pour updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- Fonction pour créer un profil automatiquement à l'inscription
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, email, full_name)
VALUES (NEW.id, NEW.email, NEW.raw_user_meta_data->>'full_name');
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION public.handle_new_user();

34
tsconfig.json Normal file
View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

190
types/database.types.ts Normal file
View File

@@ -0,0 +1,190 @@
// Types pour la base de données Supabase
export type Database = {
public: {
Tables: {
profiles: {
Row: {
id: string;
email: string;
full_name: string | null;
persona: "jeune" | "parent" | null;
stripe_customer_id: string | null;
subscription_status: "inactive" | "active" | "cancelled" | "paused";
subscription_end_date: string | null;
created_at: string;
updated_at: string;
};
Insert: {
id: string;
email: string;
full_name?: string | null;
persona?: "jeune" | "parent" | null;
stripe_customer_id?: string | null;
subscription_status?: "inactive" | "active" | "cancelled" | "paused";
subscription_end_date?: string | null;
created_at?: string;
updated_at?: string;
};
Update: {
id?: string;
email?: string;
full_name?: string | null;
persona?: "jeune" | "parent" | null;
stripe_customer_id?: string | null;
subscription_status?: "inactive" | "active" | "cancelled" | "paused";
subscription_end_date?: string | null;
updated_at?: string;
};
};
candidatures: {
Row: {
id: string;
email: string;
firstname: string;
phone: string;
persona: string;
age: number;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username: string | null;
status: "pending" | "approved" | "rejected";
created_at: string;
};
Insert: {
id?: string;
email: string;
firstname: string;
phone: string;
persona: string;
age: number;
experience: string;
time_daily: string;
availability: string;
start_date: string;
motivation: string;
monthly_goal: string;
biggest_fear: string;
tiktok_username?: string | null;
status?: "pending" | "approved" | "rejected";
created_at?: string;
};
Update: {
email?: string;
firstname?: string;
phone?: string;
persona?: string;
age?: number;
experience?: string;
time_daily?: string;
availability?: string;
start_date?: string;
motivation?: string;
monthly_goal?: string;
biggest_fear?: string;
tiktok_username?: string | null;
status?: "pending" | "approved" | "rejected";
};
};
modules: {
Row: {
id: string;
title: string;
description: string | null;
week_number: number;
order_index: number;
content_type: "video" | "pdf" | "text" | "quiz" | null;
content_url: string | null;
duration_minutes: number | null;
is_published: boolean;
created_at: string;
};
Insert: {
id?: string;
title: string;
description?: string | null;
week_number: number;
order_index: number;
content_type?: "video" | "pdf" | "text" | "quiz" | null;
content_url?: string | null;
duration_minutes?: number | null;
is_published?: boolean;
created_at?: string;
};
Update: {
title?: string;
description?: string | null;
week_number?: number;
order_index?: number;
content_type?: "video" | "pdf" | "text" | "quiz" | null;
content_url?: string | null;
duration_minutes?: number | null;
is_published?: boolean;
};
};
user_progress: {
Row: {
id: string;
user_id: string;
module_id: string;
completed: boolean;
completed_at: string | null;
notes: string | null;
created_at: string;
};
Insert: {
id?: string;
user_id: string;
module_id: string;
completed?: boolean;
completed_at?: string | null;
notes?: string | null;
created_at?: string;
};
Update: {
completed?: boolean;
completed_at?: string | null;
notes?: string | null;
};
};
payments: {
Row: {
id: string;
user_id: string;
stripe_payment_intent_id: string;
amount: number;
currency: string;
status: string;
metadata: Record<string, unknown> | null;
created_at: string;
};
Insert: {
id?: string;
user_id: string;
stripe_payment_intent_id: string;
amount: number;
currency?: string;
status: string;
metadata?: Record<string, unknown> | null;
created_at?: string;
};
Update: {
status?: string;
metadata?: Record<string, unknown> | null;
};
};
};
};
};
// Types helpers
export type Profile = Database["public"]["Tables"]["profiles"]["Row"];
export type Candidature = Database["public"]["Tables"]["candidatures"]["Row"];
export type CandidatureInsert = Database["public"]["Tables"]["candidatures"]["Insert"];
export type Module = Database["public"]["Tables"]["modules"]["Row"];
export type UserProgress = Database["public"]["Tables"]["user_progress"]["Row"];
export type Payment = Database["public"]["Tables"]["payments"]["Row"];