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

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