diff --git a/.gitignore b/.gitignore index 5ef6a52..8a5ddd2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,20 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma + +# prisma / sqlite local databases +prisma/*.db +prisma/*.db-* +prisma/data/*.db +prisma/data/*.db-* +data/*.db +data/*.db-* + +# editor / ide +.vscode/ +.idea/ + +# os files +Thumbs.db diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..7067c81 Binary files /dev/null and b/dev.db differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..81b5ac0 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +services: + formcraft: + image: node:20-alpine + container_name: formcraft + working_dir: /app + volumes: + - .:/app + - node_modules:/app/node_modules + - formcraft_data:/app/data + ports: + - "3008:3000" + environment: + - DATABASE_URL=file:./data/dev.db + command: sh -c "apk add --no-cache openssl && npm install --include=dev && npx prisma generate && npx prisma migrate deploy && npm run build && NODE_ENV=production npm start" + restart: unless-stopped + +volumes: + node_modules: + formcraft_data: diff --git a/next.config.ts b/next.config.ts index e9ffa30..7a6a06d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,54 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + // Optimisations de performance + compress: true, + poweredByHeader: false, + + // Headers de sécurité et SEO + async headers() { + return [ + { + source: "/(.*)", + headers: [ + { + key: "X-Content-Type-Options", + value: "nosniff", + }, + { + key: "X-Frame-Options", + value: "SAMEORIGIN", + }, + { + key: "X-XSS-Protection", + value: "1; mode=block", + }, + { + key: "Referrer-Policy", + value: "strict-origin-when-cross-origin", + }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + { + key: "Strict-Transport-Security", + value: "max-age=63072000; includeSubDomains; preload", + }, + ], + }, + { + // Cache assets statiques longtemps + source: "/(.*)\\.(ico|png|jpg|jpeg|gif|svg|webp|woff|woff2)", + headers: [ + { + key: "Cache-Control", + value: "public, max-age=31536000, immutable", + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index c711f7e..3ee6f6b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,15 +8,20 @@ "name": "form", "version": "0.1.0", "dependencies": { + "@prisma/client": "^5.22.0", + "dotenv": "^17.2.3", "next": "16.1.6", + "prisma": "^5.22.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", @@ -67,7 +72,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1227,6 +1231,69 @@ "node": ">=12.4.0" } }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1562,7 +1629,6 @@ "integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1577,6 +1643,13 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -1622,7 +1695,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -2122,7 +2194,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2463,7 +2534,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2782,6 +2852,18 @@ "node": ">=0.10.0" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -3031,7 +3113,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -3591,6 +3672,20 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5341,6 +5436,25 @@ "node": ">= 0.8.0" } }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -5389,7 +5503,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5399,7 +5512,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -6088,7 +6200,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -6251,7 +6362,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6386,6 +6496,19 @@ "punycode": "^2.1.0" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -6527,7 +6650,6 @@ "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", "dev": true, "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index cb76974..816758a 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,20 @@ "lint": "eslint" }, "dependencies": { + "@prisma/client": "^5.22.0", + "dotenv": "^17.2.3", "next": "16.1.6", + "prisma": "^5.22.0", "react": "19.2.3", - "react-dom": "19.2.3" + "react-dom": "19.2.3", + "uuid": "^13.0.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/uuid": "^10.0.0", "eslint": "^9", "eslint-config-next": "16.1.6", "tailwindcss": "^4", diff --git a/prisma/migrations/20260202210607_init/migration.sql b/prisma/migrations/20260202210607_init/migration.sql new file mode 100644 index 0000000..34d3352 --- /dev/null +++ b/prisma/migrations/20260202210607_init/migration.sql @@ -0,0 +1,26 @@ +-- CreateTable +CREATE TABLE "Form" ( + "id" TEXT NOT NULL PRIMARY KEY, + "title" TEXT NOT NULL, + "description" TEXT, + "fields" TEXT NOT NULL, + "secretKey" TEXT NOT NULL, + "publicId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Response" ( + "id" TEXT NOT NULL PRIMARY KEY, + "formId" TEXT NOT NULL, + "data" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Response_formId_fkey" FOREIGN KEY ("formId") REFERENCES "Form" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "Form_secretKey_key" ON "Form"("secretKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "Form_publicId_key" ON "Form"("publicId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..97e410e --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,33 @@ +// This is your Prisma schema file, +// learn more about it in the docs: https://pris.ly/d/prisma-schema + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "sqlite" + url = env("DATABASE_URL") +} + +// Modèle pour les formulaires +model Form { + id String @id @default(uuid()) + title String + description String? + fields String // JSON stringified des champs du formulaire + secretKey String @unique // Clé secrète pour accéder aux résultats + publicId String @unique // ID public pour partager le formulaire + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + responses Response[] +} + +// Modèle pour les réponses aux formulaires +model Response { + id String @id @default(uuid()) + formId String + form Form @relation(fields: [formId], references: [id], onDelete: Cascade) + data String // JSON stringified des réponses + createdAt DateTime @default(now()) +} diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000..7de09f2 Binary files /dev/null and b/public/favicon.ico differ diff --git a/public/file.svg b/public/file.svg deleted file mode 100644 index 004145c..0000000 --- a/public/file.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/globe.svg b/public/globe.svg deleted file mode 100644 index 567f17b..0000000 --- a/public/globe.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/icon-192x192.png b/public/icon-192x192.png new file mode 100644 index 0000000..7de09f2 Binary files /dev/null and b/public/icon-192x192.png differ diff --git a/public/icon-512x512.png b/public/icon-512x512.png new file mode 100644 index 0000000..83b9ed7 Binary files /dev/null and b/public/icon-512x512.png differ diff --git a/public/next.svg b/public/next.svg deleted file mode 100644 index 5174b28..0000000 --- a/public/next.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/vercel.svg b/public/vercel.svg deleted file mode 100644 index 7705396..0000000 --- a/public/vercel.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/window.svg b/public/window.svg deleted file mode 100644 index b2b2a44..0000000 --- a/public/window.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/api/forms/[publicId]/route.ts b/src/app/api/forms/[publicId]/route.ts new file mode 100644 index 0000000..0c38848 --- /dev/null +++ b/src/app/api/forms/[publicId]/route.ts @@ -0,0 +1,157 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" + +// GET - Récupérer les réponses (nécessite la clé secrète) +export async function GET( + request: NextRequest, + context: { params: Promise<{ publicId: string }> } +) { + try { + const { publicId } = await context.params + const { searchParams } = new URL(request.url) + const secretKey = searchParams.get("secret") + + if (!secretKey) { + return NextResponse.json( + { error: "Clé secrète requise" }, + { status: 401 } + ) + } + + const form = await prisma.form.findUnique({ + where: { publicId }, + include: { + responses: { + orderBy: { createdAt: "desc" }, + }, + }, + }) + + if (!form) { + return NextResponse.json( + { error: "Formulaire non trouvé" }, + { status: 404 } + ) + } + + // Vérifier la clé secrète + if (form.secretKey !== secretKey) { + return NextResponse.json( + { error: "Accès non autorisé" }, + { status: 403 } + ) + } + + return NextResponse.json({ + form: { + id: form.id, + title: form.title, + description: form.description, + fields: JSON.parse(form.fields), + publicId: form.publicId, + createdAt: form.createdAt, + }, + responses: form.responses.map((response) => ({ + id: response.id, + data: JSON.parse(response.data), + createdAt: response.createdAt, + })), + }) + } catch (error) { + console.error("Error fetching responses:", error) + return NextResponse.json( + { error: "Erreur lors de la récupération des réponses" }, + { status: 500 } + ) + } +} + +// POST - Soumettre une réponse +export async function POST( + request: NextRequest, + context: { params: Promise<{ publicId: string }> } +) { + try { + const { publicId } = await context.params + const body = await request.json() + const { data } = body + + const form = await prisma.form.findUnique({ + where: { publicId }, + }) + + if (!form) { + return NextResponse.json( + { error: "Formulaire non trouvé" }, + { status: 404 } + ) + } + + const response = await prisma.response.create({ + data: { + formId: form.id, + data: JSON.stringify(data), + }, + }) + + return NextResponse.json({ + success: true, + responseId: response.id, + }) + } catch (error) { + console.error("Error submitting response:", error) + return NextResponse.json( + { error: "Erreur lors de la soumission de la réponse" }, + { status: 500 } + ) + } +} + +// DELETE - Supprimer le formulaire (nécessite la clé secrète) +export async function DELETE( + request: NextRequest, + context: { params: Promise<{ publicId: string }> } +) { + try { + const { publicId } = await context.params + const { searchParams } = new URL(request.url) + const secretKey = searchParams.get("secret") + + if (!secretKey) { + return NextResponse.json( + { error: "Clé secrète requise" }, + { status: 401 } + ) + } + + const form = await prisma.form.findUnique({ + where: { publicId }, + }) + + if (!form) { + return NextResponse.json( + { error: "Formulaire non trouvé" }, + { status: 404 } + ) + } + + if (form.secretKey !== secretKey) { + return NextResponse.json( + { error: "Accès non autorisé" }, + { status: 403 } + ) + } + + await prisma.form.delete({ + where: { publicId }, + }) + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("Error deleting form:", error) + return NextResponse.json( + { error: "Erreur lors de la suppression du formulaire" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/forms/route.ts b/src/app/api/forms/route.ts new file mode 100644 index 0000000..779b306 --- /dev/null +++ b/src/app/api/forms/route.ts @@ -0,0 +1,87 @@ +import { NextRequest, NextResponse } from "next/server" +import { prisma } from "@/lib/prisma" +import { v4 as uuidv4 } from "uuid" +import crypto from "crypto" + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { title, description, fields } = body + + if (!title || !fields || fields.length === 0) { + return NextResponse.json( + { error: "Titre et champs requis" }, + { status: 400 } + ) + } + + const publicId = uuidv4().slice(0, 8) // ID court pour l'URL publique + const secretKey = crypto.randomBytes(32).toString("hex") // Clé secrète longue + + const form = await prisma.form.create({ + data: { + title, + description: description || null, + fields: JSON.stringify(fields), + publicId, + secretKey, + }, + }) + + return NextResponse.json({ + id: form.id, + publicId: form.publicId, + secretKey: form.secretKey, + }) + } catch (error) { + console.error("Error creating form:", error) + return NextResponse.json( + { error: "Erreur lors de la création du formulaire" }, + { status: 500 } + ) + } +} + +export async function GET(request: NextRequest) { + try { + const { searchParams } = new URL(request.url) + const publicId = searchParams.get("publicId") + + if (!publicId) { + return NextResponse.json( + { error: "ID public requis" }, + { status: 400 } + ) + } + + const form = await prisma.form.findUnique({ + where: { publicId }, + select: { + id: true, + title: true, + description: true, + fields: true, + publicId: true, + createdAt: true, + }, + }) + + if (!form) { + return NextResponse.json( + { error: "Formulaire non trouvé" }, + { status: 404 } + ) + } + + return NextResponse.json({ + ...form, + fields: JSON.parse(form.fields), + }) + } catch (error) { + console.error("Error fetching form:", error) + return NextResponse.json( + { error: "Erreur lors de la récupération du formulaire" }, + { status: 500 } + ) + } +} diff --git a/src/app/creer/layout.tsx b/src/app/creer/layout.tsx new file mode 100644 index 0000000..1db08e4 --- /dev/null +++ b/src/app/creer/layout.tsx @@ -0,0 +1,22 @@ +import { Metadata } from "next" + +export const metadata: Metadata = { + title: "Créer un formulaire gratuit en ligne", + description: "Créez votre formulaire personnalisé en quelques clics. Ajoutez des champs texte, email, sélection et plus. Partagez votre formulaire instantanément avec un lien unique. Gratuit et sans inscription.", + alternates: { + canonical: "https://form.arthurp.fr/creer", + }, + openGraph: { + title: "Créer un formulaire gratuit | FormCraft", + description: "Créez votre formulaire personnalisé en quelques clics et partagez-le instantanément.", + url: "https://form.arthurp.fr/creer", + }, +} + +export default function CreerLayout({ + children, +}: { + children: React.ReactNode +}) { + return children +} diff --git a/src/app/creer/page.tsx b/src/app/creer/page.tsx new file mode 100644 index 0000000..3cc26a6 --- /dev/null +++ b/src/app/creer/page.tsx @@ -0,0 +1,269 @@ +"use client" + +import { useState } from "react" +import { useRouter } from "next/navigation" +import { v4 as uuidv4 } from "uuid" +import { FormField, FieldType } from "@/types/form" +import { saveForm } from "@/lib/localStorage" +import FieldEditor from "@/components/FieldEditor" + +const fieldTypes: { type: FieldType; label: string; icon: React.ReactNode }[] = [ + { + type: "text", + label: "Texte court", + icon: + }, + { + type: "textarea", + label: "Texte long", + icon: + }, + { + type: "email", + label: "Email", + icon: + }, + { + type: "number", + label: "Nombre", + icon: + }, + { + type: "phone", + label: "Téléphone", + icon: + }, + { + type: "date", + label: "Date", + icon: + }, + { + type: "time", + label: "Heure", + icon: + }, + { + type: "select", + label: "Liste déroulante", + icon: + }, + { + type: "radio", + label: "Choix unique", + icon: + }, + { + type: "checkbox", + label: "Choix multiple", + icon: + }, +] + +export default function CreateFormPage() { + const router = useRouter() + const [title, setTitle] = useState("") + const [description, setDescription] = useState("") + const [fields, setFields] = useState([]) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState("") + + const addField = (type: FieldType) => { + const newField: FormField = { + id: uuidv4(), + type, + label: "", + placeholder: "", + required: false, + options: type === "select" || type === "radio" || type === "checkbox" ? ["Option 1"] : undefined, + } + setFields([...fields, newField]) + } + + const updateField = (id: string, updates: Partial) => { + setFields(fields.map(field => + field.id === id ? { ...field, ...updates } : field + )) + } + + const removeField = (id: string) => { + setFields(fields.filter(field => field.id !== id)) + } + + const moveField = (id: string, direction: "up" | "down") => { + const index = fields.findIndex(field => field.id === id) + if ( + (direction === "up" && index === 0) || + (direction === "down" && index === fields.length - 1) + ) return + + const newFields = [...fields] + const swapIndex = direction === "up" ? index - 1 : index + 1 + ;[newFields[index], newFields[swapIndex]] = [newFields[swapIndex], newFields[index]] + setFields(newFields) + } + + const handleSubmit = async () => { + if (!title.trim()) { + setError("Veuillez entrer un titre pour votre formulaire") + return + } + + if (fields.length === 0) { + setError("Veuillez ajouter au moins un champ à votre formulaire") + return + } + + const emptyLabels = fields.some(field => !field.label.trim()) + if (emptyLabels) { + setError("Veuillez remplir le libellé de tous les champs") + return + } + + setIsSubmitting(true) + setError("") + + try { + const response = await fetch("/api/forms", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ title, description, fields }), + }) + + if (!response.ok) { + throw new Error("Erreur lors de la création du formulaire") + } + + const data = await response.json() + + // Sauvegarder dans le localStorage + saveForm({ + publicId: data.publicId, + secretKey: data.secretKey, + title: title, + createdAt: new Date().toISOString(), + }) + + router.push(`/formulaire/${data.publicId}/succes?secret=${data.secretKey}`) + } catch { + setError("Une erreur est survenue. Veuillez réessayer.") + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+

Créer un formulaire

+

Ajoutez des champs et personnalisez votre formulaire

+
+ + {/* Titre et description */} +
+
+
+ + setTitle(e.target.value)} + placeholder="Ex: Formulaire d'inscription" + className="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors" + /> +
+
+ +