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:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user