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.  @@ -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 ( +
+ {session.user?.name || "Utilisateur"} +
++ {session.user?.email} +
++ {session.user?.name || "Utilisateur"} +
++ {session.user?.email} +
++ Gérez toutes vos waitlists produit en un seul endroit +
+URL publique
+ + /w/{waitlist.slug} + +Waitlist non trouvée
+ +
+
Partagez votre waitlist avec votre audience
+
+ Intégration SDK
+
+ {sdkCode}
+
+
+ Embed (iFrame)
+
+ {iframeCode}
+
+ + {waitlistTitle && `${waitlistTitle} • `} + {subscribers.length} {subscribers.length <= 1 ? "inscrit" : "inscrits"} +
++ Créez et personnalisez facilement des pages de waitlist pour votre produit. + Collectez des emails et gérez vos abonnés depuis un dashboard intuitif. +
++ Inscrivez-vous gratuitement et commencez à collecter des emails dès aujourd'hui +
+ ++ {previewData.subheadline} +
+ )} + + {previewData.description && ( ++ {previewData.description} +
+ )} + + {/* Countdown */} + {previewData.countdownEnabled && previewData.countdownDate && timeRemaining && ( ++ X personnes déjà inscrites +
++ {waitlist.subheadline} +
+ )} + + {/* Description */} + {waitlist.description && ( ++ {waitlist.description} +
+ )} + + {/* Countdown */} + {waitlist.countdownEnabled && waitlist.countdownDate && timeRemaining && ( ++ Merci pour votre inscription ! +
++ Vous êtes #{waitlist._count.subscribers} sur la liste d'attente. +
++ {waitlist._count.subscribers} {waitlist._count.subscribers <= 1 ? "personne déjà inscrite" : "personnes déjà inscrites"} +
+
+ SDK
+ + Partagez ce lien directement sur vos réseaux sociaux, dans vos emails ou sur votre site web. +
+QR Code à venir
++ Fonctionnalité Pro — + Les domaines personnalisés sont disponibles sur les plans payants. + Mettre à niveau +
++ Un certificat SSL sera automatiquement provisionné pour votre domaine personnalisé. +
+
+ {sdkCode}
+
+
+ {iframeCode}
+
+
+ {reactCode}
+
+ + Le SDK permet une intégration native dans votre application. Consultez la + documentation complète pour plus d'options. +
++ {description} +
+ )} + + {/* Countdown */} + {countdownEnabled && countdownDate && timeRemaining && ( ++ {subscriberCount} {subscriberCount <= 1 ? "personne déjà inscrite" : "personnes déjà inscrites"} +
++ {children} +
+ ) +} + +interface DialogFooterProps { + className?: string + children: React.ReactNode +} + +function DialogFooter({ className = "", children }: DialogFooterProps) { + return ( +