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:
13
.gitignore
vendored
13
.gitignore
vendored
@@ -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
36
README.md
Normal 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
116
app/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
app/(auth)/register/page.tsx
Normal file
152
app/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
133
app/(dashboard)/dashboard/page.tsx
Normal file
133
app/(dashboard)/dashboard/page.tsx
Normal 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'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'ils seront disponibles.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx
Normal file
83
app/(dashboard)/formations/[moduleId]/MarkCompleteButton.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import Button from "@/components/ui/Button";
|
||||
|
||||
interface MarkCompleteButtonProps {
|
||||
moduleId: string;
|
||||
userId: string;
|
||||
isCompleted: boolean;
|
||||
}
|
||||
|
||||
export default function MarkCompleteButton({
|
||||
moduleId,
|
||||
userId,
|
||||
isCompleted: initialCompleted,
|
||||
}: MarkCompleteButtonProps) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [completed, setCompleted] = useState(initialCompleted);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const supabase = createClient();
|
||||
|
||||
if (completed) {
|
||||
// Marquer comme non complété
|
||||
await (supabase
|
||||
.from("user_progress")
|
||||
.update({ completed: false, completed_at: null } as never)
|
||||
.eq("user_id", userId)
|
||||
.eq("module_id", moduleId));
|
||||
} else {
|
||||
// Marquer comme complété (upsert)
|
||||
await (supabase.from("user_progress").upsert({
|
||||
user_id: userId,
|
||||
module_id: moduleId,
|
||||
completed: true,
|
||||
completed_at: new Date().toISOString(),
|
||||
} as never));
|
||||
}
|
||||
|
||||
setCompleted(!completed);
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
console.error("Erreur mise a jour progression:", err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleToggle}
|
||||
loading={loading}
|
||||
variant={completed ? "secondary" : "primary"}
|
||||
>
|
||||
{completed ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-4 h-4 text-success"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Complete - Annuler
|
||||
</span>
|
||||
) : (
|
||||
"Marquer comme complete"
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
181
app/(dashboard)/formations/[moduleId]/page.tsx
Normal file
181
app/(dashboard)/formations/[moduleId]/page.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import Card from "@/components/ui/Card";
|
||||
import MarkCompleteButton from "./MarkCompleteButton";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
interface ModulePageProps {
|
||||
params: Promise<{ moduleId: string }>;
|
||||
}
|
||||
|
||||
export default async function ModulePage({ params }: ModulePageProps) {
|
||||
const { moduleId } = await params;
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Récupérer le module
|
||||
const { data: module } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("id", moduleId)
|
||||
.eq("is_published", true)
|
||||
.single() as { data: Module | null };
|
||||
|
||||
if (!module) {
|
||||
redirect("/formations");
|
||||
}
|
||||
|
||||
// Récupérer la progression pour ce module
|
||||
const { data: progress } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id)
|
||||
.eq("module_id", moduleId)
|
||||
.single() as { data: UserProgress | null };
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm mb-8">
|
||||
<a
|
||||
href="/formations"
|
||||
className="text-white/40 hover:text-white transition-colors"
|
||||
>
|
||||
Formations
|
||||
</a>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="text-white/40">Semaine {module.week_number}</span>
|
||||
<span className="text-white/20">/</span>
|
||||
<span className="text-white">{module.title}</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-primary/10 rounded-lg text-primary text-xs font-medium">
|
||||
Semaine {module.week_number}
|
||||
</span>
|
||||
{module.content_type && (
|
||||
<span className="inline-flex items-center px-2.5 py-1 bg-dark-lighter rounded-lg text-white/40 text-xs font-medium uppercase">
|
||||
{module.content_type}
|
||||
</span>
|
||||
)}
|
||||
{module.duration_minutes && (
|
||||
<span className="text-white/30 text-xs">
|
||||
{module.duration_minutes} min
|
||||
</span>
|
||||
)}
|
||||
{progress?.completed && (
|
||||
<span className="inline-flex items-center gap-1 px-2.5 py-1 bg-success/10 rounded-lg text-success text-xs font-medium">
|
||||
<svg
|
||||
className="w-3 h-3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={3}
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
Complete
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-white mb-3">{module.title}</h1>
|
||||
{module.description && (
|
||||
<p className="text-white/60 text-lg">{module.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Contenu du module */}
|
||||
<Card className="mb-8">
|
||||
{/* Video */}
|
||||
{module.content_type === "video" && module.content_url && (
|
||||
<div className="aspect-video bg-dark-lighter rounded-2xl overflow-hidden mb-6">
|
||||
<iframe
|
||||
src={module.content_url}
|
||||
className="w-full h-full"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowFullScreen
|
||||
title={module.title}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PDF */}
|
||||
{module.content_type === "pdf" && module.content_url && (
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href={module.content_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-4 py-3 bg-primary/10 text-primary rounded-xl hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.5}
|
||||
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
|
||||
/>
|
||||
</svg>
|
||||
Telecharger le PDF
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Placeholder si pas de contenu */}
|
||||
{!module.content_url && (
|
||||
<div className="aspect-video bg-dark-lighter rounded-2xl flex items-center justify-center mb-6">
|
||||
<div className="text-center">
|
||||
<div className="text-4xl mb-3">🎬</div>
|
||||
<p className="text-white/40 text-sm">
|
||||
Le contenu sera bientot disponible
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<a
|
||||
href="/formations"
|
||||
className="text-white/40 hover:text-white text-sm transition-colors flex items-center gap-1"
|
||||
>
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M15 19l-7-7 7-7"
|
||||
/>
|
||||
</svg>
|
||||
Retour aux formations
|
||||
</a>
|
||||
|
||||
<MarkCompleteButton
|
||||
moduleId={moduleId}
|
||||
userId={user!.id}
|
||||
isCompleted={progress?.completed || false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
app/(dashboard)/formations/page.tsx
Normal file
108
app/(dashboard)/formations/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import ModuleCard from "@/components/dashboard/ModuleCard";
|
||||
import ProgressBar from "@/components/dashboard/ProgressBar";
|
||||
import type { Module, UserProgress } from "@/types/database.types";
|
||||
|
||||
export default async function FormationsPage() {
|
||||
const supabase = await createClient();
|
||||
|
||||
const {
|
||||
data: { user },
|
||||
} = await supabase.auth.getUser();
|
||||
|
||||
// Récupérer les modules publiés
|
||||
const { data: modules } = await supabase
|
||||
.from("modules")
|
||||
.select("*")
|
||||
.eq("is_published", true)
|
||||
.order("week_number", { ascending: true })
|
||||
.order("order_index", { ascending: true }) as { data: Module[] | null };
|
||||
|
||||
// Récupérer la progression
|
||||
const { data: progress } = await supabase
|
||||
.from("user_progress")
|
||||
.select("*")
|
||||
.eq("user_id", user!.id) as { data: UserProgress[] | null };
|
||||
|
||||
// Grouper les modules par semaine
|
||||
const modulesByWeek = (modules || []).reduce(
|
||||
(acc, module) => {
|
||||
const week = module.week_number;
|
||||
if (!acc[week]) acc[week] = [];
|
||||
acc[week].push(module);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<number, Module[]>
|
||||
);
|
||||
|
||||
const totalModules = modules?.length || 0;
|
||||
const completedModules =
|
||||
progress?.filter((p) => p.completed).length || 0;
|
||||
const progressPercent =
|
||||
totalModules > 0 ? (completedModules / totalModules) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl">
|
||||
{/* Header */}
|
||||
<div className="mb-10">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Formations</h1>
|
||||
<p className="text-white/60 mb-6">
|
||||
Progression dans le programme HookLab - 8 semaines.
|
||||
</p>
|
||||
<ProgressBar
|
||||
value={progressPercent}
|
||||
label={`${completedModules} modules completes sur ${totalModules}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Modules par semaine */}
|
||||
{Object.entries(modulesByWeek).map(([week, weekModules]) => {
|
||||
const weekCompleted =
|
||||
weekModules?.filter((m) =>
|
||||
progress?.find((p) => p.module_id === m.id && p.completed)
|
||||
).length || 0;
|
||||
const weekTotal = weekModules?.length || 0;
|
||||
|
||||
return (
|
||||
<div key={week} className="mb-10">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-white">
|
||||
Semaine {week}
|
||||
</h2>
|
||||
<span className="text-white/30 text-sm">
|
||||
{weekCompleted}/{weekTotal} completes
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{weekModules?.map((module) => {
|
||||
const moduleProgress = progress?.find(
|
||||
(p) => p.module_id === module.id
|
||||
);
|
||||
return (
|
||||
<ModuleCard
|
||||
key={module.id}
|
||||
module={module}
|
||||
progress={moduleProgress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Message si aucun module */}
|
||||
{totalModules === 0 && (
|
||||
<div className="text-center py-20">
|
||||
<div className="text-5xl mb-4">📚</div>
|
||||
<h3 className="text-white font-semibold text-lg mb-2">
|
||||
Aucun module disponible
|
||||
</h3>
|
||||
<p className="text-white/40 text-sm">
|
||||
Les modules de formation seront bientot disponibles.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
app/(dashboard)/layout.tsx
Normal file
44
app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
240
app/(dashboard)/profil/page.tsx
Normal file
240
app/(dashboard)/profil/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
427
app/(marketing)/candidature/page.tsx
Normal file
427
app/(marketing)/candidature/page.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
79
app/(marketing)/merci/page.tsx
Normal file
79
app/(marketing)/merci/page.tsx
Normal 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'accueil</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
app/(marketing)/page.tsx
Normal file
23
app/(marketing)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
132
app/api/candidature/route.ts
Normal file
132
app/api/candidature/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
app/api/formations/[moduleId]/route.ts
Normal file
70
app/api/formations/[moduleId]/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
65
app/api/stripe/create-checkout/route.ts
Normal file
65
app/api/stripe/create-checkout/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
229
app/api/stripe/webhook/route.ts
Normal file
229
app/api/stripe/webhook/route.ts
Normal 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
92
app/globals.css
Normal 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
33
app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
146
components/dashboard/ModuleCard.tsx
Normal file
146
components/dashboard/ModuleCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
components/dashboard/ProgressBar.tsx
Normal file
36
components/dashboard/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
156
components/dashboard/Sidebar.tsx
Normal file
156
components/dashboard/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
components/marketing/FAQ.tsx
Normal file
110
components/marketing/FAQ.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
129
components/marketing/Footer.tsx
Normal file
129
components/marketing/Footer.tsx
Normal 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'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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
91
components/marketing/Hero.tsx
Normal file
91
components/marketing/Hero.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
components/marketing/Method.tsx
Normal file
123
components/marketing/Method.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
components/marketing/Navbar.tsx
Normal file
145
components/marketing/Navbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
components/marketing/PersonaCards.tsx
Normal file
100
components/marketing/PersonaCards.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
104
components/marketing/Pricing.tsx
Normal file
104
components/marketing/Pricing.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
components/marketing/Testimonials.tsx
Normal file
91
components/marketing/Testimonials.tsx
Normal 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">
|
||||
“{t.content}”
|
||||
</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
85
components/ui/Button.tsx
Normal 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
32
components/ui/Card.tsx
Normal 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
77
components/ui/Input.tsx
Normal 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
18
eslint.config.mjs
Normal 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
6
lib/stripe/client.ts
Normal 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
9
lib/supabase/client.ts
Normal 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!
|
||||
);
|
||||
59
lib/supabase/middleware.ts
Normal file
59
lib/supabase/middleware.ts
Normal 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
45
lib/supabase/server.ts
Normal 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
28
lib/utils.ts
Normal 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
18
middleware.ts
Normal 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
7
next.config.ts
Normal 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
7107
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal 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
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/file.svg
Normal file
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
1
public/window.svg
Normal 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 |
133
supabase/migrations/001_initial_schema.sql
Normal file
133
supabase/migrations/001_initial_schema.sql
Normal 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
34
tsconfig.json
Normal 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
190
types/database.types.ts
Normal 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"];
|
||||
Reference in New Issue
Block a user