diff --git a/.gitignore b/.gitignore index 29081ec..eccd8b6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,22 @@ build/ # Plan file (development only) PLAN.md + +# Next.js / Node.js (for nextjs-saas/) +node_modules/ +.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions +.next/ +out/ +*.tsbuildinfo +next-env.d.ts +.vercel +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* diff --git a/README.md b/README.md index 3f048eb..9efb51e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # Waitlist +Ce dépôt contient deux projets de waitlist : + +1. **Waitlist Page (Go)** - Une page de waitlist simple et légère déployable en une commande Docker +2. **Waitlist SaaS (Next.js)** - Une application SaaS complète pour créer et gérer plusieurs pages de waitlist avec authentification et dashboard (dans le dossier `nextjs-saas/`) + +--- + +## 🚀 Waitlist Page (Go) + A beautiful, dead-simple waitlist page that deploys in seconds. One Docker command, zero configuration required. ![License](https://img.shields.io/badge/license-MIT-blue.svg) @@ -217,3 +226,52 @@ Create a new Web Service, connect your repo, and set environment variables in th ## License MIT + +--- + +## 🎯 Waitlist SaaS (Next.js) + +Une application SaaS complète pour créer et gérer plusieurs pages de waitlist avec authentification et dashboard. + +### Fonctionnalités + +- ✅ **Authentification complète** : Inscription et connexion sécurisées +- ✅ **Dashboard intuitif** : Gérez toutes vos waitlists depuis un seul endroit +- ✅ **Personnalisation complète** : Couleurs, logo, contenu personnalisables +- ✅ **Gestion des abonnés** : Visualisez et exportez vos inscriptions en CSV +- ✅ **Pages publiques** : URLs personnalisables pour chaque waitlist (`/w/[slug]`) +- ✅ **Champs personnalisables** : Collectez nom, email, entreprise selon vos besoins + +### Technologies + +- **Next.js 16** avec App Router +- **TypeScript** pour la sécurité de type +- **Prisma** avec SQLite (facilement migrable vers PostgreSQL) +- **NextAuth.js** pour l'authentification +- **Tailwind CSS** pour le styling +- **bcryptjs** pour le hachage des mots de passe + +### Installation + +```bash +cd nextjs-saas +npm install +npx prisma migrate dev +npx prisma generate +``` + +Configurez les variables d'environnement dans `.env.local` : + +```env +DATABASE_URL="file:./dev.db" +NEXTAUTH_SECRET="votre-secret-key-changez-en-production" +NEXTAUTH_URL="http://localhost:3000" +``` + +Lancez le serveur de développement : + +```bash +npm run dev +``` + +Pour plus de détails, consultez le [README du projet Next.js](./nextjs-saas/README.md). diff --git a/nextjs-saas/.gitignore b/nextjs-saas/.gitignore new file mode 100644 index 0000000..e7493ed --- /dev/null +++ b/nextjs-saas/.gitignore @@ -0,0 +1,55 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +/app/generated/prisma + +# uploaded files +/public/uploads/* +!/public/uploads/.gitkeep + +# database files +*.db +*.db-journal +/prisma/dev.db +/prisma/dev.db-journal +dev.db +dev.db-journal diff --git a/nextjs-saas/README.md b/nextjs-saas/README.md new file mode 100644 index 0000000..4452e2d --- /dev/null +++ b/nextjs-saas/README.md @@ -0,0 +1,152 @@ +# Waitlist SaaS - Générateur de pages d'attente + +Un mini SaaS permettant de créer et personnaliser des pages de waitlist pour votre produit. + +## 🚀 Fonctionnalités + +- ✅ **Authentification complète** : Inscription et connexion sécurisées +- ✅ **Dashboard intuitif** : Gérez toutes vos waitlists depuis un seul endroit +- ✅ **Personnalisation complète** : Couleurs, logo, contenu personnalisables +- ✅ **Gestion des abonnés** : Visualisez et exportez vos inscriptions en CSV +- ✅ **Pages publiques** : URLs personnalisables pour chaque waitlist (`/w/[slug]`) +- ✅ **Champs personnalisables** : Collectez nom, email, entreprise selon vos besoins + +## 🛠️ Technologies + +- **Next.js 16** avec App Router +- **TypeScript** pour la sécurité de type +- **Prisma** avec SQLite (facilement migrable vers PostgreSQL) +- **NextAuth.js** pour l'authentification +- **Tailwind CSS** pour le styling +- **bcryptjs** pour le hachage des mots de passe + +## 📦 Installation + +1. Clonez le projet et installez les dépendances : + +```bash +npm install +``` + +2. Configurez la base de données : + +```bash +npx prisma migrate dev +npx prisma generate +``` + +3. Configurez les variables d'environnement dans `.env.local` : + +```env +DATABASE_URL="file:./dev.db" +NEXTAUTH_SECRET="votre-secret-key-changez-en-production" +NEXTAUTH_URL="http://localhost:3000" +``` + +4. Lancez le serveur de développement : + +```bash +npm run dev +``` + +5. Ouvrez [http://localhost:3000](http://localhost:3000) dans votre navigateur. + +## 📁 Structure du projet + +``` +saas-app/ +├── app/ +│ ├── api/ # Routes API +│ │ ├── auth/ # Authentification +│ │ ├── waitlists/ # Gestion des waitlists +│ │ └── subscribe/ # Inscription publique +│ ├── dashboard/ # Dashboard utilisateur +│ ├── login/ # Page de connexion +│ ├── register/ # Page d'inscription +│ └── w/[slug]/ # Page publique de waitlist +├── lib/ +│ ├── auth.ts # Configuration NextAuth +│ └── prisma.ts # Client Prisma +├── prisma/ +│ └── schema.prisma # Schéma de base de données +└── types/ + └── next-auth.d.ts # Types TypeScript pour NextAuth +``` + +## 🎯 Utilisation + +### Créer un compte + +1. Allez sur `/register` pour créer un compte +2. Connectez-vous sur `/login` + +### Créer une waitlist + +1. Dans le dashboard, cliquez sur "Créer une waitlist" +2. Remplissez les informations : + - **Slug** : L'URL de votre page (ex: `ma-super-app`) + - **Titre** : Le nom de votre produit + - **Headline** : Le titre principal affiché sur la page + - **Couleurs** : Personnalisez l'apparence + - **Logo** : Ajoutez votre logo (URL) + - **Champs** : Choisissez quelles informations collecter + +3. Votre page sera accessible sur `/w/[votre-slug]` + +### Gérer les abonnés + +1. Dans le dashboard, cliquez sur "Abonnés" pour une waitlist +2. Visualisez tous les inscrits avec leur position +3. Exportez la liste en CSV + +## 🔐 Sécurité + +- Les mots de passe sont hashés avec bcrypt +- Les routes API sont protégées par authentification +- Validation des données côté serveur +- Protection CSRF avec NextAuth + +## 🚀 Déploiement + +### Variables d'environnement à configurer + +```env +DATABASE_URL="votre-url-de-base-de-donnees" +NEXTAUTH_SECRET="générez-une-clé-secrète-aléatoire" +NEXTAUTH_URL="https://votre-domaine.com" +``` + +### Migration vers PostgreSQL + +Pour utiliser PostgreSQL en production, modifiez `prisma/schema.prisma` : + +```prisma +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} +``` + +Puis exécutez : + +```bash +npx prisma migrate deploy +``` + +## 📝 Prochaines améliorations possibles + +- [ ] Intégration avec des services d'email (SendGrid, Mailchimp) +- [ ] Analytics et statistiques détaillées +- [ ] Templates de design prédéfinis +- [ ] Intégration avec Stripe pour les paiements +- [ ] API publique pour intégrations tierces +- [ ] Multi-langues +- [ ] Notifications par email aux abonnés + +## 📄 Licence + +MIT + +--- + +**Développé avec ❤️ en Next.js 16** diff --git a/nextjs-saas/app/api/auth/[...nextauth]/route.ts b/nextjs-saas/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..866b2be --- /dev/null +++ b/nextjs-saas/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,3 @@ +import { handlers } from "@/lib/auth" + +export const { GET, POST } = handlers diff --git a/nextjs-saas/app/api/auth/register/route.ts b/nextjs-saas/app/api/auth/register/route.ts new file mode 100644 index 0000000..4cb9044 --- /dev/null +++ b/nextjs-saas/app/api/auth/register/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server" +import bcrypt from "bcryptjs" +import { prisma } from "@/lib/prisma" + +export async function POST(request: Request) { + try { + const { email, password, name } = await request.json() + + if (!email || !password) { + return NextResponse.json( + { error: "Email et mot de passe requis" }, + { status: 400 } + ) + } + + const existingUser = await prisma.user.findUnique({ + where: { email } + }) + + if (existingUser) { + return NextResponse.json( + { error: "Cet email est déjà utilisé" }, + { status: 400 } + ) + } + + const hashedPassword = await bcrypt.hash(password, 10) + + const user = await prisma.user.create({ + data: { + email, + password: hashedPassword, + name: name || null, + } + }) + + return NextResponse.json( + { message: "Compte créé avec succès", userId: user.id }, + { status: 201 } + ) + } catch (error) { + console.error("Erreur lors de l'inscription:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/auth/session/route.ts b/nextjs-saas/app/api/auth/session/route.ts new file mode 100644 index 0000000..0ec0e80 --- /dev/null +++ b/nextjs-saas/app/api/auth/session/route.ts @@ -0,0 +1,33 @@ +import { auth } from "@/lib/auth" +import { NextResponse } from "next/server" + +export const runtime = "nodejs" + +export async function GET() { + try { + const session = await auth() + + // Format attendu par next-auth/react SessionProvider + if (!session) { + return NextResponse.json(null) + } + + // Handle expires - it might be a Date object or a string + let expires: string | null = null + if (session.expires) { + if (session.expires instanceof Date) { + expires = session.expires.toISOString() + } else if (typeof session.expires === 'string') { + expires = session.expires + } + } + + return NextResponse.json({ + user: session.user, + expires, + }) + } catch (error) { + console.error("Session API error:", error) + return NextResponse.json(null) + } +} diff --git a/nextjs-saas/app/api/public/waitlists/[slug]/route.ts b/nextjs-saas/app/api/public/waitlists/[slug]/route.ts new file mode 100644 index 0000000..9386068 --- /dev/null +++ b/nextjs-saas/app/api/public/waitlists/[slug]/route.ts @@ -0,0 +1,49 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +// GET - Récupérer une waitlist publique par son slug +export async function GET( + request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params + const waitlist = await prisma.waitlist.findUnique({ + where: { slug }, + select: { + id: true, + slug: true, + title: true, + description: true, + headline: true, + subheadline: true, + theme: true, + primaryColor: true, + backgroundColor: true, + logoUrl: true, + collectName: true, + collectCompany: true, + countdownEnabled: true, + countdownDate: true, + _count: { + select: { subscribers: true } + } + } + }) + + if (!waitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + return NextResponse.json(waitlist) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/subscribe/route.ts b/nextjs-saas/app/api/subscribe/route.ts new file mode 100644 index 0000000..b3dffa6 --- /dev/null +++ b/nextjs-saas/app/api/subscribe/route.ts @@ -0,0 +1,74 @@ +import { NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +export async function POST(request: Request) { + try { + const { waitlistId, email, name, company, customData } = await request.json() + + if (!waitlistId || !email) { + return NextResponse.json( + { error: "ID de waitlist et email requis" }, + { status: 400 } + ) + } + + // Vérifier que la waitlist existe + const waitlist = await prisma.waitlist.findUnique({ + where: { id: waitlistId } + }) + + if (!waitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + // Vérifier si l'email est déjà inscrit + const existingSubscriber = await prisma.subscriber.findUnique({ + where: { + waitlistId_email: { + waitlistId, + email + } + } + }) + + if (existingSubscriber) { + return NextResponse.json( + { error: "Cet email est déjà inscrit à cette waitlist" }, + { status: 400 } + ) + } + + // Compter le nombre d'inscrits pour déterminer la position + const subscriberCount = await prisma.subscriber.count({ + where: { waitlistId } + }) + + const subscriber = await prisma.subscriber.create({ + data: { + waitlistId, + email, + name: name || null, + company: company || null, + customData: customData ? JSON.stringify(customData) : null, + position: subscriberCount + 1 + } + }) + + return NextResponse.json( + { + message: "Inscription réussie", + position: subscriber.position + }, + { status: 201 } + ) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/upload/logo/route.ts b/nextjs-saas/app/api/upload/logo/route.ts new file mode 100644 index 0000000..3ca61a5 --- /dev/null +++ b/nextjs-saas/app/api/upload/logo/route.ts @@ -0,0 +1,75 @@ +import { NextResponse } from "next/server" +import { writeFile, mkdir } from "fs/promises" +import { join } from "path" +import { existsSync } from "fs" +import { auth } from "@/lib/auth" + +export async function POST(request: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const formData = await request.formData() + const file = formData.get("file") as File + + if (!file) { + return NextResponse.json( + { error: "Aucun fichier fourni" }, + { status: 400 } + ) + } + + // Vérifier le type de fichier + const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/svg+xml"] + if (!allowedTypes.includes(file.type)) { + return NextResponse.json( + { error: "Type de fichier non autorisé. Utilisez PNG, JPEG, GIF, WebP ou SVG" }, + { status: 400 } + ) + } + + // Vérifier la taille (max 5MB) + const maxSize = 5 * 1024 * 1024 // 5MB + if (file.size > maxSize) { + return NextResponse.json( + { error: "Le fichier est trop volumineux. Taille maximale : 5MB" }, + { status: 400 } + ) + } + + // Créer le dossier uploads/logos s'il n'existe pas + const uploadsDir = join(process.cwd(), "public", "uploads", "logos") + if (!existsSync(uploadsDir)) { + await mkdir(uploadsDir, { recursive: true }) + } + + // Générer un nom de fichier unique + const timestamp = Date.now() + const randomString = Math.random().toString(36).substring(2, 15) + const fileExtension = file.name.split(".").pop() + const fileName = `${timestamp}-${randomString}.${fileExtension}` + + // Sauvegarder le fichier + const bytes = await file.arrayBuffer() + const buffer = Buffer.from(bytes) + const filePath = join(uploadsDir, fileName) + await writeFile(filePath, buffer) + + // Retourner l'URL du fichier + const fileUrl = `/uploads/logos/${fileName}` + + return NextResponse.json({ url: fileUrl }, { status: 200 }) + } catch (error) { + console.error("Erreur lors de l'upload:", error) + return NextResponse.json( + { error: "Erreur lors de l'upload du fichier" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/waitlists/[id]/route.ts b/nextjs-saas/app/api/waitlists/[id]/route.ts new file mode 100644 index 0000000..5d4c6cf --- /dev/null +++ b/nextjs-saas/app/api/waitlists/[id]/route.ts @@ -0,0 +1,192 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +// GET - Récupérer une waitlist spécifique +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + const waitlist = await prisma.waitlist.findFirst({ + where: { + id, + userId + }, + include: { + _count: { + select: { subscribers: true } + } + } + }) + + if (!waitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + return NextResponse.json(waitlist) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} + +// PUT - Mettre à jour une waitlist +export async function PUT( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const data = await request.json() + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + // Vérifier que la waitlist appartient à l'utilisateur + const existingWaitlist = await prisma.waitlist.findFirst({ + where: { + id, + userId + } + }) + + if (!existingWaitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + // Si le slug change, vérifier qu'il n'existe pas déjà + if (data.slug && data.slug !== existingWaitlist.slug) { + const slugExists = await prisma.waitlist.findUnique({ + where: { slug: data.slug } + }) + + if (slugExists) { + return NextResponse.json( + { error: "Ce slug est déjà utilisé" }, + { status: 400 } + ) + } + } + + // Préparer les données avec gestion spéciale pour countdownDate + const updateData: any = { + slug: data.slug, + title: data.title, + description: data.description || null, + headline: data.headline, + subheadline: data.subheadline || null, + theme: data.theme || existingWaitlist.theme, + primaryColor: data.primaryColor || existingWaitlist.primaryColor, + backgroundColor: data.backgroundColor || existingWaitlist.backgroundColor, + logoUrl: data.logoUrl || null, + collectName: data.collectName ?? existingWaitlist.collectName, + collectCompany: data.collectCompany ?? existingWaitlist.collectCompany, + countdownEnabled: data.countdownEnabled ?? existingWaitlist.countdownEnabled, + updatedAt: new Date() + } + + // Gérer countdownDate si countdownEnabled est modifié + if (data.countdownEnabled) { + if (data.countdownDate) { + // Si c'est une string datetime-local, la convertir en Date + updateData.countdownDate = new Date(data.countdownDate) + } else if (existingWaitlist.countdownDate) { + // Garder la date existante si pas de nouvelle date fournie + updateData.countdownDate = existingWaitlist.countdownDate + } else { + updateData.countdownDate = null + } + } else { + updateData.countdownDate = null + } + + const waitlist = await prisma.waitlist.update({ + where: { id }, + data: updateData + }) + + return NextResponse.json(waitlist) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} + +// DELETE - Supprimer une waitlist +export async function DELETE( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + const waitlist = await prisma.waitlist.findFirst({ + where: { + id, + userId + } + }) + + if (!waitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + await prisma.waitlist.delete({ + where: { id } + }) + + return NextResponse.json({ message: "Waitlist supprimée" }) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/waitlists/[id]/subscribers/route.ts b/nextjs-saas/app/api/waitlists/[id]/subscribers/route.ts new file mode 100644 index 0000000..fbc1d87 --- /dev/null +++ b/nextjs-saas/app/api/waitlists/[id]/subscribers/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +// GET - Récupérer tous les abonnés d'une waitlist +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const session = await auth() + const { id } = await params + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + // Vérifier que la waitlist appartient à l'utilisateur + const waitlist = await prisma.waitlist.findFirst({ + where: { + id, + userId + } + }) + + if (!waitlist) { + return NextResponse.json( + { error: "Waitlist non trouvée" }, + { status: 404 } + ) + } + + const subscribers = await prisma.subscriber.findMany({ + where: { waitlistId: id }, + orderBy: { createdAt: "asc" } + }) + + return NextResponse.json(subscribers) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/waitlists/check-slug/route.ts b/nextjs-saas/app/api/waitlists/check-slug/route.ts new file mode 100644 index 0000000..1d3feb9 --- /dev/null +++ b/nextjs-saas/app/api/waitlists/check-slug/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +// GET - Vérifier si un slug est disponible +export async function GET(request: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const { searchParams } = new URL(request.url) + const slug = searchParams.get("slug") + const excludeId = searchParams.get("excludeId") // Pour exclure la waitlist en cours d'édition + + if (!slug) { + return NextResponse.json( + { error: "Slug requis" }, + { status: 400 } + ) + } + + // Valider le format du slug + const slugRegex = /^[a-z0-9-]+$/ + if (!slugRegex.test(slug)) { + return NextResponse.json({ + available: false, + reason: "format", + message: "L'URL ne peut contenir que des lettres minuscules, des chiffres et des tirets" + }) + } + + // Vérifier la longueur + if (slug.length < 3) { + return NextResponse.json({ + available: false, + reason: "length", + message: "L'URL doit contenir au moins 3 caractères" + }) + } + + if (slug.length > 50) { + return NextResponse.json({ + available: false, + reason: "length", + message: "L'URL ne peut pas dépasser 50 caractères" + }) + } + + // Vérifier si le slug existe déjà + const existingWaitlist = await prisma.waitlist.findUnique({ + where: { slug }, + select: { id: true } + }) + + // Si le slug existe mais c'est la même waitlist (en édition), c'est OK + if (existingWaitlist && excludeId && existingWaitlist.id === excludeId) { + return NextResponse.json({ + available: true, + message: "URL disponible" + }) + } + + if (existingWaitlist) { + return NextResponse.json({ + available: false, + reason: "taken", + message: "Cette URL est déjà utilisée" + }) + } + + return NextResponse.json({ + available: true, + message: "URL disponible" + }) + } catch (error) { + console.error("Erreur vérification slug:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/api/waitlists/route.ts b/nextjs-saas/app/api/waitlists/route.ts new file mode 100644 index 0000000..996b47a --- /dev/null +++ b/nextjs-saas/app/api/waitlists/route.ts @@ -0,0 +1,119 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import { prisma } from "@/lib/prisma" + +// GET - Récupérer toutes les waitlists de l'utilisateur +export async function GET() { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + const waitlists = await prisma.waitlist.findMany({ + where: { userId }, + include: { + _count: { + select: { subscribers: true } + } + }, + orderBy: { createdAt: "desc" } + }) + + return NextResponse.json(waitlists) + } catch (error) { + console.error("Erreur:", error) + return NextResponse.json( + { error: "Erreur serveur" }, + { status: 500 } + ) + } +} + +// POST - Créer une nouvelle waitlist +export async function POST(request: Request) { + try { + const session = await auth() + + if (!session?.user?.id) { + return NextResponse.json( + { error: "Non authentifié" }, + { status: 401 } + ) + } + + const data = await request.json() + const { + slug, + title, + description, + headline, + subheadline, + theme, + primaryColor, + backgroundColor, + logoUrl, + collectName, + collectCompany, + countdownEnabled, + countdownDate, + } = data + + if (!slug || !title) { + return NextResponse.json( + { error: "Slug et titre requis" }, + { status: 400 } + ) + } + + // Utiliser le titre comme headline si non fourni + const finalHeadline = headline || title + + // Vérifier si le slug existe déjà + const existingWaitlist = await prisma.waitlist.findUnique({ + where: { slug } + }) + + if (existingWaitlist) { + return NextResponse.json( + { error: "Ce slug est déjà utilisé" }, + { status: 400 } + ) + } + + const userId = typeof session.user.id === 'string' ? session.user.id : String(session.user.id) + + const waitlist = await prisma.waitlist.create({ + data: { + userId, + slug, + title, + description: description || null, + headline: finalHeadline, + subheadline: subheadline || null, + theme: theme || "dark-modern", + primaryColor: primaryColor || "#000000", + backgroundColor: backgroundColor || "#ffffff", + logoUrl: logoUrl || null, + collectName: collectName ?? true, + collectCompany: collectCompany ?? false, + countdownEnabled: countdownEnabled ?? false, + countdownDate: countdownEnabled && countdownDate && countdownDate.trim() !== "" ? new Date(countdownDate) : null, + } + }) + + return NextResponse.json(waitlist, { status: 201 }) + } catch (error) { + console.error("Erreur lors de la création de la waitlist:", error) + return NextResponse.json( + { error: error instanceof Error ? error.message : "Erreur serveur" }, + { status: 500 } + ) + } +} diff --git a/nextjs-saas/app/dashboard/layout.tsx b/nextjs-saas/app/dashboard/layout.tsx new file mode 100644 index 0000000..9799507 --- /dev/null +++ b/nextjs-saas/app/dashboard/layout.tsx @@ -0,0 +1,234 @@ +"use client" + +import { useSession, signOut } from "next-auth/react" +import { useRouter, usePathname } from "next/navigation" +import Link from "next/link" +import { useEffect, useState } from "react" +import { ChevronLeft, ChevronRight, List } from "lucide-react" + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode +}) { + const { data: session, status } = useSession() + const router = useRouter() + const pathname = usePathname() + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) + + useEffect(() => { + if (status === "unauthenticated") { + router.push("/login") + } + }, [status, router]) + + if (status === "loading") { + return ( +
+
Chargement...
+
+ ) + } + + if (!session) { + return null + } + + const navigation = [ + { name: "Mes Waitlists", href: "/dashboard" }, + ] + + return ( +
+ {/* Sidebar */} +
+
+
+ {/* Logo */} +
+ + {!sidebarCollapsed && ( + + Waitlist SaaS + + )} + {sidebarCollapsed && ( + + W + + )} + +
+ + {/* Toggle button */} + + + {/* Navigation */} +
+ +
+ + {/* User section */} + {!sidebarCollapsed && ( +
+
+
+
+

+ {session.user?.name || "Utilisateur"} +

+

+ {session.user?.email} +

+
+
+ +
+
+ )} +
+
+
+ + {/* Mobile sidebar */} + {mobileMenuOpen && ( +
+
setMobileMenuOpen(false)} /> +
+
+ +
+
+
+ + Waitlist SaaS + +
+ +
+
+
+
+

+ {session.user?.name || "Utilisateur"} +

+

+ {session.user?.email} +

+
+
+ +
+
+
+ )} + + {/* Main content */} +
+ {/* Mobile header */} +
+
+ + + Waitlist SaaS + +
+
+
+ + {/* Page content */} +
+
+ {children} +
+
+
+
+ ) +} diff --git a/nextjs-saas/app/dashboard/page.tsx b/nextjs-saas/app/dashboard/page.tsx new file mode 100644 index 0000000..23c2ed2 --- /dev/null +++ b/nextjs-saas/app/dashboard/page.tsx @@ -0,0 +1,182 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Trash2, Users } from "lucide-react" +import { toast } from "sonner" + +interface Waitlist { + id: string + slug: string + title: string + description: string | null + headline: string + _count: { + subscribers: number + } + createdAt: string +} + +export default function DashboardPage() { + const [waitlists, setWaitlists] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetchWaitlists() + }, []) + + const fetchWaitlists = async () => { + try { + const response = await fetch("/api/waitlists") + if (response.ok) { + const data = await response.json() + setWaitlists(data) + } + } catch (error) { + console.error("Erreur:", error) + } finally { + setLoading(false) + } + } + + const handleDelete = async (id: string) => { + const waitlist = waitlists.find(w => w.id === id) + const waitlistTitle = waitlist?.title || "cette waitlist" + + if (!confirm(`Êtes-vous sûr de vouloir supprimer "${waitlistTitle}" ?`)) { + return + } + + try { + const response = await fetch(`/api/waitlists/${id}`, { + method: "DELETE", + }) + + if (response.ok) { + toast.success("Waitlist supprimée", { + description: `"${waitlistTitle}" a été supprimée avec succès.`, + }) + fetchWaitlists() + } else { + const data = await response.json() + toast.error("Erreur lors de la suppression", { + description: data.error || "Une erreur est survenue.", + }) + } + } catch (error) { + console.error("Erreur:", error) + toast.error("Erreur lors de la suppression", { + description: "Une erreur est survenue lors de la suppression de la waitlist.", + }) + } + } + + if (loading) { + return ( +
+
Chargement de vos waitlists...
+
+ ) + } + + return ( +
+ {/* Header séparé */} +
+
+
+

Mes Waitlists

+

+ Gérez toutes vos waitlists produit en un seul endroit +

+
+ +
+
+ + {/* Contenu principal */} +
+ {waitlists.length === 0 ? ( + + + Aucune waitlist pour le moment + + Créez votre première page d'attente pour commencer à collecter des inscriptions. + + + + + + + ) : ( +
+ {waitlists.map((waitlist) => ( + + + {waitlist.title} + {waitlist.description && ( + + {waitlist.description} + + )} + + +
+ + + {waitlist._count.subscribers} {waitlist._count.subscribers <= 1 ? "inscrit" : "inscrits"} + +
+
+

URL publique

+ + /w/{waitlist.slug} + +
+
+ + + + + +
+ ))} +
+ )} +
+
+ ) +} diff --git a/nextjs-saas/app/dashboard/waitlists/[id]/page.tsx b/nextjs-saas/app/dashboard/waitlists/[id]/page.tsx new file mode 100644 index 0000000..b4cfbad --- /dev/null +++ b/nextjs-saas/app/dashboard/waitlists/[id]/page.tsx @@ -0,0 +1,912 @@ +"use client" + +import { useEffect, useState, useCallback } from "react" +import { useParams } from "next/navigation" +import Link from "next/link" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Checkbox } from "@/components/ui/checkbox" +import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs" +import { Alert, AlertDescription } from "@/components/ui/alert" +import { + ArrowLeft, + Check, + X, + Loader2, + Save, + Palette, + Settings, + Timer, + Monitor, + Smartphone, + Share2, + Users, + Copy, + Link2, + Globe, + Code, + ExternalLink, + ChevronDown, + Info +} from "lucide-react" +import { themes, type ThemeId } from "@/lib/themes" +import { Logo } from "@/components/Logo" +import { WaitlistPreview } from "@/components/WaitlistPreview" + +// Debounce hook +function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(handler) + } + }, [value, delay]) + + return debouncedValue +} + +interface Waitlist { + id: string + slug: string + title: string + description: string | null + headline: string + subheadline: string | null + primaryColor: string + backgroundColor: string + logoUrl: string | null + theme: ThemeId + collectName: boolean + collectCompany: boolean + countdownEnabled: boolean + countdownDate: string | null + _count?: { + subscribers: number + } +} + +interface SlugStatus { + checking: boolean + available: boolean | null + message: string +} + +export default function EditWaitlistPage() { + const params = useParams() + const id = params.id as string + + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState("") + const [formData, setFormData] = useState(null) + const [originalSlug, setOriginalSlug] = useState("") + const [activeTab, setActiveTab] = useState("general") + const [previewDevice, setPreviewDevice] = useState<"desktop" | "mobile">("desktop") + const [shareOpen, setShareOpen] = useState(false) + const [copiedItem, setCopiedItem] = useState(null) + + const [slugStatus, setSlugStatus] = useState({ + checking: false, + available: null, + message: "", + }) + const [logoPreview, setLogoPreview] = useState("") + const [uploadingLogo, setUploadingLogo] = useState(false) + + const debouncedSlug = useDebounce(formData?.slug || "", 500) + + // URL de base + const baseUrl = typeof window !== "undefined" ? window.location.origin : "" + const publicUrl = formData ? `${baseUrl}/w/${formData.slug}` : "" + + useEffect(() => { + fetchWaitlist() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [id]) + + // Fermer le popover de partage quand on clique ailleurs + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (shareOpen && !target.closest('[data-share-popover]')) { + setShareOpen(false) + } + } + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, [shareOpen]) + + const fetchWaitlist = async () => { + try { + const response = await fetch(`/api/waitlists/${id}`) + if (response.ok) { + const data = await response.json() + const formattedCountdownDate = data.countdownDate + ? new Date(data.countdownDate).toISOString().slice(0, 16) + : "" + + setFormData({ + ...data, + theme: data.theme || "dark-modern", + countdownDate: formattedCountdownDate, + headline: data.headline || data.title, + subheadline: null, + }) + setOriginalSlug(data.slug) + if (data.logoUrl) { + setLogoPreview(data.logoUrl) + } + } + } catch (error) { + console.error("Erreur:", error) + } finally { + setLoading(false) + } + } + + // Vérifier la disponibilité du slug + const checkSlugAvailability = useCallback(async (slug: string) => { + if (!slug || slug.length < 3) { + setSlugStatus({ + checking: false, + available: null, + message: slug.length > 0 ? "Minimum 3 caractères" : "", + }) + return + } + + if (slug === originalSlug) { + setSlugStatus({ + checking: false, + available: true, + message: "URL actuelle", + }) + return + } + + setSlugStatus(prev => ({ ...prev, checking: true })) + + try { + const response = await fetch(`/api/waitlists/check-slug?slug=${encodeURIComponent(slug)}&excludeId=${id}`) + const data = await response.json() + + setSlugStatus({ + checking: false, + available: data.available, + message: data.message, + }) + } catch { + setSlugStatus({ + checking: false, + available: null, + message: "Erreur de vérification", + }) + } + }, [originalSlug, id]) + + useEffect(() => { + if (debouncedSlug && formData) { + checkSlugAvailability(debouncedSlug) + } + }, [debouncedSlug, checkSlugAvailability, formData]) + + const handleLogoChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file || !formData) return + + const allowedTypes = ["image/png", "image/jpeg", "image/jpg", "image/gif", "image/webp", "image/svg+xml"] + if (!allowedTypes.includes(file.type)) { + toast.error("Type de fichier non autorisé", { + description: "Utilisez PNG, JPEG, GIF, WebP ou SVG", + }) + return + } + + const maxSize = 5 * 1024 * 1024 + if (file.size > maxSize) { + toast.error("Fichier trop volumineux", { + description: "Taille maximale : 5MB", + }) + return + } + + const reader = new FileReader() + reader.onloadend = () => { + setLogoPreview(reader.result as string) + } + reader.readAsDataURL(file) + + setUploadingLogo(true) + try { + const uploadFormData = new FormData() + uploadFormData.append("file", file) + + const response = await fetch("/api/upload/logo", { + method: "POST", + body: uploadFormData, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "Erreur lors de l'upload") + } + + const data = await response.json() + setFormData({ ...formData, logoUrl: data.url }) + toast.success("Logo uploadé avec succès") + } catch (error) { + console.error("Erreur upload:", error) + toast.error("Erreur lors de l'upload du logo") + setLogoPreview("") + } finally { + setUploadingLogo(false) + } + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!formData) return + + setError("") + setSaving(true) + + if (slugStatus.available === false) { + setError("L'URL n'est pas disponible") + setSaving(false) + return + } + + try { + const submitData = { + ...formData, + headline: formData.title, + subheadline: null, + primaryColor: themes[formData.theme].primaryColor, + backgroundColor: themes[formData.theme].backgroundColor, + countdownDate: formData.countdownEnabled && formData.countdownDate + ? (typeof formData.countdownDate === 'string' + ? formData.countdownDate + : new Date(formData.countdownDate).toISOString().slice(0, 16)) + : null, + } + + const response = await fetch(`/api/waitlists/${id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(submitData), + }) + + const data = await response.json() + + if (!response.ok) { + setError(data.error || "Une erreur est survenue") + toast.error("Erreur lors de la sauvegarde", { + description: data.error || "Une erreur est survenue", + }) + return + } + + setFormData({ + ...data, + theme: data.theme || "dark-modern", + countdownDate: data.countdownDate + ? new Date(data.countdownDate).toISOString().slice(0, 16) + : "", + }) + setOriginalSlug(data.slug) + + if (data.logoUrl && !logoPreview) { + setLogoPreview(data.logoUrl) + } + + toast.success("Modifications enregistrées") + } catch (error) { + console.error("Erreur:", error) + setError("Une erreur est survenue") + toast.error("Erreur lors de la sauvegarde") + } finally { + setSaving(false) + } + } + + const copyToClipboard = async (text: string, itemId: string) => { + try { + await navigator.clipboard.writeText(text) + setCopiedItem(itemId) + toast.success("Copié !") + setTimeout(() => setCopiedItem(null), 2000) + } catch { + toast.error("Erreur lors de la copie") + } + } + + const getSlugIcon = () => { + if (slugStatus.checking) { + return + } + if (slugStatus.available === true) { + return + } + if (slugStatus.available === false) { + return + } + return null + } + + // Codes SDK + const sdkCode = ` + +
` + + const iframeCode = `` + + if (loading) { + return ( +
+ +
+ ) + } + + if (!formData) { + return ( +
+

Waitlist non trouvée

+ +
+ ) + } + + const subscriberCount = formData._count?.subscribers || 0 + + return ( +
+ {/* Header compact */} +
+
+ +
+

{formData.title}

+

+ + {subscriberCount} inscrit{subscriberCount > 1 ? "s" : ""} +

+
+ + {/* Actions groupées : Sauvegarder puis Partager */} +
+ + + {/* Bouton Partager avec dropdown */} +
+ + + {/* Dropdown de partage */} + {shareOpen && ( +
+ {/* Header du dropdown */} +
+

Options de partage

+

Partagez votre waitlist avec votre audience

+
+ +
+ {/* URL directe */} +
+
+ + Lien public +
+
+ + + +
+
+ + {/* Domaine personnalisé */} +
+
+ + Domaine personnalisé +
+
+ + +
+
+ + Fonctionnalité Pro — Mettre à niveau +
+
+ + {/* Intégration SDK */} +
+
+
+ + Intégration SDK +
+ +
+
+                        {sdkCode}
+                      
+
+ + {/* iFrame */} +
+
+
+ + Embed (iFrame) +
+ +
+
+                        {iframeCode}
+                      
+
+
+
+ )} +
+
+
+
+ + {/* Main content - Split view */} +
+ {/* Left Panel - Form */} +
+
+ {/* Tabs Navigation */} +
+ + + + + Général + + + + Design + + + + Options + + + +
+ + {/* Scrollable Form Content */} +
+ {error && ( + + {error} + + )} + + + {/* Tab: Général */} + + {/* Titre */} +
+ + setFormData({ ...formData, title: e.target.value })} + placeholder="Mon super produit" + className="h-9" + /> +
+ + {/* URL / Slug */} +
+ +
+
+ + /w/ + + + setFormData({ + ...formData, + slug: e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), + }) + } + placeholder="mon-super-produit" + className="rounded-l-none h-9 pr-9" + /> +
+ {getSlugIcon()} +
+
+
+ {slugStatus.message && ( +

+ {slugStatus.message} +

+ )} +
+ + {/* Description */} +
+ +