add form site

This commit is contained in:
Puechberty Arthur
2026-03-02 13:38:13 +01:00
parent c6d7ce8900
commit 1611ad7440
43 changed files with 2805 additions and 97 deletions
+17
View File
@@ -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
BIN
View File
Binary file not shown.
+19
View File
@@ -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:
+48 -1
View File
@@ -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;
+134 -12
View File
@@ -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"
}
+6 -1
View File
@@ -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",
@@ -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");
+3
View File
@@ -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"
+33
View File
@@ -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())
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

+157
View File
@@ -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 }
)
}
}
+87
View File
@@ -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 }
)
}
}
+22
View File
@@ -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
}
+269
View File
@@ -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: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h8m-8 6h16" /></svg>
},
{
type: "textarea",
label: "Texte long",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" /></svg>
},
{
type: "email",
label: "Email",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
},
{
type: "number",
label: "Nombre",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 20l4-16m2 16l4-16M6 9h14M4 15h14" /></svg>
},
{
type: "phone",
label: "Téléphone",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" /></svg>
},
{
type: "date",
label: "Date",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" /></svg>
},
{
type: "time",
label: "Heure",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
},
{
type: "select",
label: "Liste déroulante",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 9l4-4 4 4m0 6l-4 4-4-4" /></svg>
},
{
type: "radio",
label: "Choix unique",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" strokeWidth={2} /><circle cx="12" cy="12" r="4" fill="currentColor" /></svg>
},
{
type: "checkbox",
label: "Choix multiple",
icon: <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
},
]
export default function CreateFormPage() {
const router = useRouter()
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [fields, setFields] = useState<FormField[]>([])
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<FormField>) => {
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 (
<div className="min-h-screen bg-gray-50 py-6 sm:py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="mb-6 sm:mb-8">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Créer un formulaire</h1>
<p className="mt-1 sm:mt-2 text-sm sm:text-base text-gray-600">Ajoutez des champs et personnalisez votre formulaire</p>
</div>
{/* Titre et description */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="space-y-4">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700 mb-1">
Titre du formulaire *
</label>
<input
type="text"
id="title"
value={title}
onChange={(e) => 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"
/>
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700 mb-1">
Description (optionnel)
</label>
<textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Décrivez votre formulaire..."
rows={3}
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 resize-none"
/>
</div>
</div>
</div>
{/* Ajouter des champs */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Ajouter un champ</h2>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-3">
{fieldTypes.map((fieldType) => (
<button
key={fieldType.type}
onClick={() => addField(fieldType.type)}
className="flex flex-col items-center p-4 border border-gray-200 rounded-lg hover:border-blue-500 hover:bg-blue-50 transition-colors group"
>
<div className="text-gray-500 group-hover:text-blue-600 mb-2">
{fieldType.icon}
</div>
<span className="text-sm text-gray-700 group-hover:text-blue-700 text-center">
{fieldType.label}
</span>
</button>
))}
</div>
</div>
{/* Liste des champs */}
{fields.length > 0 && (
<div className="space-y-4 mb-6">
<h2 className="text-lg font-semibold text-gray-900">Champs du formulaire</h2>
{fields.map((field, index) => (
<FieldEditor
key={field.id}
field={field}
index={index}
totalFields={fields.length}
onUpdate={(updates) => updateField(field.id, updates)}
onRemove={() => removeField(field.id)}
onMove={(direction) => moveField(field.id, direction)}
/>
))}
</div>
)}
{/* Message d'erreur */}
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6">
{error}
</div>
)}
{/* Bouton de soumission */}
<div className="flex">
<button
onClick={handleSubmit}
disabled={isSubmitting}
className="w-full sm:w-auto sm:ml-auto px-8 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Création en cours...
</>
) : (
<>
Créer le formulaire
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</>
)}
</button>
</div>
</div>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
"use client"
import { useEffect } from "react"
import Link from "next/link"
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string }
reset: () => void
}) {
useEffect(() => {
console.error(error)
}, [error])
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Une erreur est survenue</h1>
<p className="text-gray-600 mb-6 max-w-md">
Quelque chose s&apos;est mal passé. Veuillez réessayer ou retourner à l&apos;accueil.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<button
onClick={reset}
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
Réessayer
</button>
<Link
href="/"
className="px-6 py-3 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium"
>
Retour à l&apos;accueil
</Link>
</div>
</div>
</div>
)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,261 @@
"use client"
import { useState } from "react"
import { FormField } from "@/types/form"
interface FormData {
id: string
title: string
description: string | null
fields: FormField[]
publicId: string
}
interface Props {
form: FormData
}
export default function FormResponseClient({ form }: Props) {
const [responses, setResponses] = useState<Record<string, string | string[]>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSubmitted, setIsSubmitted] = useState(false)
const [error, setError] = useState("")
const handleInputChange = (fieldId: string, value: string | string[]) => {
setResponses((prev) => ({
...prev,
[fieldId]: value,
}))
}
const handleCheckboxChange = (fieldId: string, option: string, checked: boolean) => {
const currentValues = (responses[fieldId] as string[]) || []
const newValues = checked
? [...currentValues, option]
: currentValues.filter((v) => v !== option)
handleInputChange(fieldId, newValues)
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
// Vérifier les champs obligatoires
const missingRequired = form.fields.filter((field) => {
if (!field.required) return false
const value = responses[field.id]
if (Array.isArray(value)) return value.length === 0
return !value || value.trim() === ""
})
if (missingRequired.length > 0) {
setError("Veuillez remplir tous les champs obligatoires")
return
}
setIsSubmitting(true)
try {
const response = await fetch(`/api/forms/${form.publicId}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data: responses }),
})
if (!response.ok) {
throw new Error("Erreur lors de l'envoi")
}
setIsSubmitted(true)
} catch {
setError("Une erreur est survenue. Veuillez réessayer.")
} finally {
setIsSubmitting(false)
}
}
if (isSubmitted) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl font-bold text-gray-900 mb-2">Merci !</h1>
<p className="text-gray-600">Votre réponse a é enregistrée avec succès.</p>
</div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 py-6 sm:py-8">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 sm:p-8 mb-4 sm:mb-6">
<h1 className="text-xl sm:text-2xl font-bold text-gray-900 mb-2">{form.title}</h1>
{form.description && (
<p className="text-gray-600">{form.description}</p>
)}
</div>
<form onSubmit={handleSubmit}>
<div className="space-y-4">
{form.fields.map((field) => (
<div key={field.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<label className="block text-sm font-medium text-gray-900 mb-3">
{field.label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
{field.type === "text" && (
<input
type="text"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
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"
/>
)}
{field.type === "email" && (
<input
type="email"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
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"
/>
)}
{field.type === "number" && (
<input
type="number"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
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"
/>
)}
{field.type === "phone" && (
<input
type="tel"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
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"
/>
)}
{field.type === "textarea" && (
<textarea
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
placeholder={field.placeholder}
rows={4}
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 resize-none"
/>
)}
{field.type === "date" && (
<input
type="date"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
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"
/>
)}
{field.type === "time" && (
<input
type="time"
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
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"
/>
)}
{field.type === "select" && (
<select
value={(responses[field.id] as string) || ""}
onChange={(e) => handleInputChange(field.id, e.target.value)}
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"
>
<option value="">Sélectionnez une option</option>
{field.options?.map((option, index) => (
<option key={index} value={option}>
{option}
</option>
))}
</select>
)}
{field.type === "radio" && (
<div className="space-y-2">
{field.options?.map((option, index) => (
<label key={index} className="flex items-center space-x-3 cursor-pointer">
<input
type="radio"
name={field.id}
value={option}
checked={(responses[field.id] as string) === option}
onChange={(e) => handleInputChange(field.id, e.target.value)}
className="w-4 h-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
<span className="text-gray-700">{option}</span>
</label>
))}
</div>
)}
{field.type === "checkbox" && (
<div className="space-y-2">
{field.options?.map((option, index) => (
<label key={index} className="flex items-center space-x-3 cursor-pointer">
<input
type="checkbox"
checked={((responses[field.id] as string[]) || []).includes(option)}
onChange={(e) => handleCheckboxChange(field.id, option, e.target.checked)}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<span className="text-gray-700">{option}</span>
</label>
))}
</div>
)}
</div>
))}
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mt-6">
{error}
</div>
)}
<div className="mt-6">
<button
type="submit"
disabled={isSubmitting}
className="w-full px-8 py-4 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center"
>
{isSubmitting ? (
<>
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Envoi en cours...
</>
) : (
"Envoyer ma réponse"
)}
</button>
</div>
</form>
</div>
</div>
)
}
+57
View File
@@ -0,0 +1,57 @@
import { prisma } from "@/lib/prisma"
import { notFound } from "next/navigation"
import { Metadata } from "next"
import FormResponseClient from "./FormResponseClient"
interface Props {
params: Promise<{ publicId: string }>
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { publicId } = await params
const form = await prisma.form.findUnique({
where: { publicId },
select: { title: true, description: true },
})
if (!form) {
return {
title: "Formulaire non trouvé",
}
}
return {
title: form.title,
description: form.description || `Répondez au formulaire "${form.title}"`,
robots: {
index: false,
follow: false,
},
}
}
export default async function FormResponsePage({ params }: Props) {
const { publicId } = await params
const form = await prisma.form.findUnique({
where: { publicId },
select: {
id: true,
title: true,
description: true,
fields: true,
publicId: true,
},
})
if (!form) {
notFound()
}
const formData = {
...form,
fields: JSON.parse(form.fields),
}
return <FormResponseClient form={formData} />
}
@@ -0,0 +1,248 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useEffect, useState, Suspense, use } from "react"
import { FormField } from "@/types/form"
import Link from "next/link"
interface FormData {
id: string
title: string
description: string | null
fields: FormField[]
publicId: string
createdAt: string
}
interface Response {
id: string
data: Record<string, string | string[]>
createdAt: string
}
function ResultsContent({ publicId }: { publicId: string }) {
const searchParams = useSearchParams()
const secretKey = searchParams.get("secret")
const [form, setForm] = useState<FormData | null>(null)
const [responses, setResponses] = useState<Response[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [copied, setCopied] = useState(false)
useEffect(() => {
const fetchData = async () => {
if (!secretKey) {
setError("Clé secrète manquante. Vous n'avez pas accès aux résultats.")
setLoading(false)
return
}
try {
const response = await fetch(`/api/forms/${publicId}?secret=${secretKey}`)
if (response.status === 403) {
setError("Accès non autorisé. Clé secrète invalide.")
setLoading(false)
return
}
if (response.status === 404) {
setError("Formulaire non trouvé.")
setLoading(false)
return
}
if (!response.ok) {
throw new Error("Erreur lors de la récupération des données")
}
const data = await response.json()
setForm(data.form)
setResponses(data.responses)
} catch {
setError("Une erreur est survenue lors de la récupération des données.")
} finally {
setLoading(false)
}
}
fetchData()
}, [publicId, secretKey])
const copyShareLink = async () => {
const shareUrl = typeof window !== "undefined"
? `${window.location.origin}/formulaire/${publicId}`
: `/formulaire/${publicId}`
await navigator.clipboard.writeText(shareUrl)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const exportToCSV = () => {
if (!form || responses.length === 0) return
const headers = ["Date", ...form.fields.map(f => f.label)]
const rows = responses.map(response => {
const date = new Date(response.createdAt).toLocaleString("fr-FR")
const values = form.fields.map(field => {
const value = response.data[field.id]
if (Array.isArray(value)) return value.join(", ")
return value || ""
})
return [date, ...values]
})
const csvContent = [
headers.map(h => `"${h}"`).join(","),
...rows.map(row => row.map(cell => `"${cell}"`).join(","))
].join("\n")
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
const link = document.createElement("a")
link.href = URL.createObjectURL(blob)
link.download = `${form.title.replace(/[^a-z0-9]/gi, "_")}_reponses.csv`
link.click()
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
if (error) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-8 max-w-md w-full text-center">
<div className="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
</div>
<h1 className="text-xl font-bold text-gray-900 mb-2">Accès refusé</h1>
<p className="text-gray-600 mb-6">{error}</p>
<Link
href="/"
className="text-blue-600 hover:text-blue-700 font-medium"
>
Retour à l&apos;accueil
</Link>
</div>
</div>
)
}
if (!form) return null
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8">
{/* En-tête */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div>
<h1 className="text-xl sm:text-2xl font-bold text-gray-900">{form.title}</h1>
{form.description && (
<p className="text-gray-600 mt-1">{form.description}</p>
)}
<p className="text-sm text-gray-500 mt-2">
{responses.length} réponse{responses.length !== 1 ? "s" : ""}
</p>
</div>
<div className="flex flex-wrap gap-3">
<button
onClick={copyShareLink}
className="px-4 py-2 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors flex items-center text-sm font-medium"
>
{copied ? (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copié !
</>
) : (
<>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Partager
</>
)}
</button>
{responses.length > 0 && (
<button
onClick={exportToCSV}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center text-sm font-medium"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Exporter CSV
</button>
)}
</div>
</div>
</div>
{/* Réponses */}
{responses.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-12 text-center">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Aucune réponse pour le moment</h2>
<p className="text-gray-600">Partagez le lien de votre formulaire pour commencer à collecter des réponses.</p>
</div>
) : (
<div className="space-y-4">
{responses.map((response, index) => (
<div key={response.id} className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-0 mb-4">
<span className="text-sm font-medium text-gray-500">
Réponse #{responses.length - index}
</span>
<span className="text-sm text-gray-400">
{new Date(response.createdAt).toLocaleString("fr-FR")}
</span>
</div>
<div className="grid gap-4 md:grid-cols-2">
{form.fields.map((field) => (
<div key={field.id} className="bg-gray-50 rounded-lg p-4">
<p className="text-sm font-medium text-gray-500 mb-1">{field.label}</p>
<p className="text-gray-900">
{Array.isArray(response.data[field.id])
? (response.data[field.id] as string[]).join(", ") || "-"
: response.data[field.id] || "-"}
</p>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default function ResultsPage({ params }: { params: Promise<{ publicId: string }> }) {
const { publicId } = use(params)
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
}>
<ResultsContent publicId={publicId} />
</Suspense>
)
}
@@ -0,0 +1,155 @@
"use client"
import { useSearchParams } from "next/navigation"
import { useState, Suspense, useEffect, use } from "react"
import Link from "next/link"
function SuccessContent({ publicId }: { publicId: string }) {
const searchParams = useSearchParams()
const secretKey = searchParams.get("secret")
const [copied, setCopied] = useState<"link" | "admin" | null>(null)
const formUrl = typeof window !== "undefined"
? `${window.location.origin}/formulaire/${publicId}`
: `/formulaire/${publicId}`
const adminUrl = typeof window !== "undefined"
? `${window.location.origin}/formulaire/${publicId}/resultats?secret=${secretKey}`
: `/formulaire/${publicId}/resultats?secret=${secretKey}`
const copyToClipboard = async (text: string, type: "link" | "admin") => {
await navigator.clipboard.writeText(text)
setCopied(type)
setTimeout(() => setCopied(null), 2000)
}
return (
<div className="min-h-screen bg-gray-50 py-8 sm:py-12">
<div className="max-w-2xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-8 text-center mb-6 sm:mb-8">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
</div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Formulaire créé !</h1>
<p className="text-gray-600">Votre formulaire est prêt à être partagé.</p>
</div>
<div className="space-y-6">
{/* Lien de partage */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Lien de partage</h2>
<p className="text-sm text-gray-600 mb-4">
Partagez ce lien pour permettre aux gens de répondre à votre formulaire.
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
readOnly
value={formUrl}
className="flex-1 min-w-0 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 text-sm truncate"
/>
<button
onClick={() => copyToClipboard(formUrl, "link")}
className="w-full sm:w-auto px-4 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center shrink-0"
>
{copied === "link" ? (
<>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copié
</>
) : (
<>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copier
</>
)}
</button>
</div>
</div>
{/* Lien admin */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-2">Accès aux résultats</h2>
<p className="text-sm text-gray-600 mb-4">
<strong className="text-red-600">Important :</strong> Ce lien est privé et vous permet de voir les réponses.
Ne le partagez pas !
</p>
<div className="flex flex-col sm:flex-row gap-2">
<input
type="text"
readOnly
value={adminUrl}
className="flex-1 min-w-0 px-4 py-3 bg-gray-50 border border-gray-200 rounded-lg text-gray-700 text-sm truncate"
/>
<button
onClick={() => copyToClipboard(adminUrl, "admin")}
className="w-full sm:w-auto px-4 py-3 bg-gray-800 text-white rounded-lg hover:bg-gray-900 transition-colors flex items-center justify-center shrink-0"
>
{copied === "admin" ? (
<>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Copié
</>
) : (
<>
<svg className="w-5 h-5 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
Copier
</>
)}
</button>
</div>
</div>
{/* Actions */}
<div className="flex flex-col sm:flex-row gap-4">
<Link
href={`/formulaire/${publicId}`}
className="flex-1 px-6 py-3 bg-white border border-gray-200 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors text-center font-medium"
>
Voir le formulaire
</Link>
<Link
href={`/formulaire/${publicId}/resultats?secret=${secretKey}`}
className="flex-1 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-center font-medium"
>
Voir les résultats
</Link>
</div>
<div className="text-center">
<Link
href="/creer"
className="text-blue-600 hover:text-blue-700 text-sm font-medium"
>
Créer un autre formulaire
</Link>
</div>
</div>
</div>
</div>
)
}
export default function SuccessPage({ params }: { params: Promise<{ publicId: string }> }) {
const { publicId } = use(params)
return (
<Suspense fallback={
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
}>
<SuccessContent publicId={publicId} />
</Suspense>
)
}
+32 -10
View File
@@ -8,19 +8,41 @@
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--font-sans: var(--font-inter);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
/* Optimisations de performance */
html {
scroll-behavior: smooth;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
font-family: var(--font-sans), system-ui, sans-serif;
}
/* Respect des préférences d'animation réduites */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
/* Focus visible pour l'accessibilité */
:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
border-radius: 4px;
}
/* Optimisation des images */
img {
content-visibility: auto;
}
+121 -12
View File
@@ -1,20 +1,114 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import type { Metadata, Viewport } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
const geistSans = Geist({
variable: "--font-geist-sans",
const inter = Inter({
variable: "--font-inter",
subsets: ["latin"],
display: "swap",
preload: true,
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 5,
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#2563eb" },
{ media: "(prefers-color-scheme: dark)", color: "#1e40af" },
],
};
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: {
default: "FormCraft - Créez vos formulaires gratuitement | Alternative à Google Forms",
template: "%s | FormCraft"
},
description: "Créez des formulaires professionnels gratuitement en quelques clics avec FormCraft. Partagez-les via un lien unique, collectez et analysez les réponses instantanément. Sans inscription requise. Alternative française à Google Forms.",
keywords: [
"formulaire en ligne", "créer formulaire gratuit", "questionnaire en ligne",
"sondage gratuit", "enquête en ligne", "Google Forms alternative",
"formulaire sans inscription", "collecte de données", "formulaire français",
"créateur de formulaires", "partager formulaire", "formulaire professionnel",
],
authors: [{ name: "FormCraft", url: "https://form.arthurp.fr" }],
creator: "FormCraft",
publisher: "FormCraft",
metadataBase: new URL("https://form.arthurp.fr"),
alternates: {
canonical: "/",
languages: {
"fr-FR": "/",
},
},
icons: {
icon: [
{ url: "/favicon.ico", sizes: "any" },
{ url: "/icon-192x192.png", sizes: "192x192", type: "image/png" },
],
apple: [
{ url: "/icon-192x192.png", sizes: "192x192", type: "image/png" },
],
},
openGraph: {
type: "website",
locale: "fr_FR",
url: "https://form.arthurp.fr",
siteName: "FormCraft",
title: "FormCraft - Créez vos formulaires gratuitement",
description: "Créez des formulaires professionnels, partagez-les et collectez les réponses instantanément. Gratuit et sans inscription.",
images: [
{
url: "/og-image.png",
width: 1200,
height: 630,
alt: "FormCraft - Créateur de formulaires en ligne gratuit",
type: "image/png",
},
],
},
twitter: {
card: "summary_large_image",
title: "FormCraft - Créez vos formulaires gratuitement",
description: "Créez des formulaires professionnels, partagez-les et collectez les réponses instantanément. Gratuit et sans inscription.",
images: ["/og-image.png"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
category: "technology",
classification: "Web Application",
};
const jsonLd = {
"@context": "https://schema.org",
"@type": "WebApplication",
name: "FormCraft",
url: "https://form.arthurp.fr",
description:
"Créez des formulaires professionnels gratuitement en quelques clics. Partagez-les via un lien unique et collectez les réponses instantanément.",
applicationCategory: "BusinessApplication",
operatingSystem: "Web",
offers: {
"@type": "Offer",
price: "0",
priceCurrency: "EUR",
},
inLanguage: "fr-FR",
creator: {
"@type": "Organization",
name: "FormCraft",
url: "https://form.arthurp.fr",
},
};
export default function RootLayout({
@@ -23,11 +117,26 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="fr" dir="ltr">
<head>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
</head>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
className={`${inter.variable} font-sans antialiased bg-gray-50 min-h-screen`}
>
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-100 focus:px-4 focus:py-2 focus:bg-blue-600 focus:text-white focus:rounded-lg"
>
Aller au contenu principal
</a>
<Header />
<main id="main-content" className="pt-16">
{children}
</main>
</body>
</html>
);
+10
View File
@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center" role="status" aria-label="Chargement en cours">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto" aria-hidden="true"></div>
<p className="mt-4 text-gray-600">Chargement...</p>
</div>
</div>
)
}
+37
View File
@@ -0,0 +1,37 @@
import { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'FormCraft - Créateur de formulaires gratuit',
short_name: 'FormCraft',
description: 'Créez des formulaires professionnels gratuitement. Partagez-les via un lien unique et collectez les réponses instantanément. Sans inscription.',
start_url: '/',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#2563eb',
orientation: 'portrait-primary',
categories: ['productivity', 'utilities', 'business'],
lang: 'fr',
dir: 'ltr',
icons: [
{
src: '/icon-192x192.png',
sizes: '192x192',
type: 'image/png',
purpose: 'any',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any',
},
{
src: '/icon-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}
+22
View File
@@ -0,0 +1,22 @@
import { Metadata } from "next"
export const metadata: Metadata = {
title: "Mes formulaires - Gérer et consulter",
description: "Retrouvez et gérez tous vos formulaires créés avec FormCraft. Consultez les réponses, partagez vos formulaires et exportez vos données facilement.",
alternates: {
canonical: "https://form.arthurp.fr/mes-formulaires",
},
openGraph: {
title: "Mes formulaires | FormCraft",
description: "Gérez tous vos formulaires, consultez les réponses et exportez vos données.",
url: "https://form.arthurp.fr/mes-formulaires",
},
}
export default function MesFormulairesLayout({
children,
}: {
children: React.ReactNode
}) {
return children
}
+194
View File
@@ -0,0 +1,194 @@
"use client"
import { useEffect, useState } from "react"
import Link from "next/link"
import { getMyForms, removeForm, LocalStorageForm } from "@/lib/localStorage"
export default function MyFormsPage() {
const [forms, setForms] = useState<LocalStorageForm[]>([])
const [loading, setLoading] = useState(true)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
useEffect(() => {
setForms(getMyForms())
setLoading(false)
}, [])
const handleDelete = async (publicId: string, secretKey: string) => {
setDeleting(true)
try {
const response = await fetch(`/api/forms/${publicId}?secret=${secretKey}`, {
method: "DELETE",
})
if (response.ok) {
removeForm(publicId)
setForms(getMyForms())
}
} catch (error) {
console.error("Error deleting form:", error)
} finally {
setDeleting(false)
setDeleteConfirm(null)
}
}
const copyLink = async (publicId: string) => {
const url = `${window.location.origin}/formulaire/${publicId}`
await navigator.clipboard.writeText(url)
}
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
)
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6 sm:mb-8">
<div>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">Mes formulaires</h1>
<p className="mt-1 sm:mt-2 text-sm sm:text-base text-gray-600">
Retrouvez tous vos formulaires créés sur cet appareil.
</p>
</div>
<Link
href="/creer"
className="w-full sm:w-auto px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center font-medium"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Nouveau formulaire
</Link>
</div>
{forms.length === 0 ? (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 sm:p-12 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-10 h-10 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Aucun formulaire</h2>
<p className="text-gray-600 mb-6">
Vous n&apos;avez pas encore créé de formulaire sur cet appareil.
</p>
<Link
href="/creer"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Créer mon premier formulaire
</Link>
</div>
) : (
<div className="space-y-4">
{forms.map((form) => (
<div
key={form.publicId}
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6"
>
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex-1">
<h2 className="text-lg font-semibold text-gray-900">{form.title}</h2>
<p className="text-sm text-gray-500 mt-1">
Créé le {new Date(form.createdAt).toLocaleDateString("fr-FR", {
day: "numeric",
month: "long",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</p>
</div>
<div className="flex flex-wrap gap-2">
<button
onClick={() => copyLink(form.publicId)}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center text-sm font-medium"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
Partager
</button>
<Link
href={`/formulaire/${form.publicId}`}
className="px-3 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors flex items-center text-sm font-medium"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Voir
</Link>
<Link
href={`/formulaire/${form.publicId}/resultats?secret=${form.secretKey}`}
className="px-3 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center text-sm font-medium"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
Résultats
</Link>
{deleteConfirm === form.publicId ? (
<div className="flex items-center gap-2">
<button
onClick={() => handleDelete(form.publicId, form.secretKey)}
disabled={deleting}
className="px-3 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors text-sm font-medium disabled:opacity-50"
>
{deleting ? "..." : "Confirmer"}
</button>
<button
onClick={() => setDeleteConfirm(null)}
className="px-3 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition-colors text-sm font-medium"
>
Annuler
</button>
</div>
) : (
<button
onClick={() => setDeleteConfirm(form.publicId)}
className="px-3 py-2 bg-red-100 text-red-700 rounded-lg hover:bg-red-200 transition-colors flex items-center text-sm font-medium"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
Supprimer
</button>
)}
</div>
</div>
</div>
))}
</div>
)}
{/* Information */}
<div className="mt-8 bg-blue-50 border border-blue-100 rounded-xl p-6">
<div className="flex items-start space-x-3">
<svg className="w-6 h-6 text-blue-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div>
<h3 className="font-medium text-blue-900">Information importante</h3>
<p className="text-sm text-blue-700 mt-1">
Vos formulaires sont sauvegardés localement sur cet appareil.
Si vous effacez les données de votre navigateur ou changez d&apos;appareil,
vous perdrez l&apos;accès aux résultats. Pensez à sauvegarder les liens d&apos;administration.
</p>
</div>
</div>
</div>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
import Link from "next/link"
export default function NotFound() {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-4">
<div className="text-center">
<div className="w-24 h-24 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<svg className="w-12 h-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<h1 className="text-4xl font-bold text-gray-900 mb-2">404</h1>
<h2 className="text-xl font-semibold text-gray-700 mb-4">Page non trouvée</h2>
<p className="text-gray-600 mb-8 max-w-md">
Désolé, la page que vous recherchez n&apos;existe pas ou a é déplacée.
</p>
<Link
href="/"
className="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors font-medium"
>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
Retour à l&apos;accueil
</Link>
</div>
</div>
)
}
+109
View File
@@ -0,0 +1,109 @@
import { ImageResponse } from "next/og"
export const runtime = "edge"
export const alt = "FormCraft - Créateur de formulaires en ligne gratuit"
export const size = {
width: 1200,
height: 630,
}
export const contentType = "image/png"
export default async function Image() {
return new ImageResponse(
(
<div
style={{
background: "linear-gradient(135deg, #1e40af 0%, #3b82f6 50%, #60a5fa 100%)",
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
padding: "60px",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
marginBottom: "40px",
}}
>
<div
style={{
width: "80px",
height: "80px",
background: "white",
borderRadius: "20px",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginRight: "24px",
}}
>
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="#2563eb"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span
style={{
fontSize: "64px",
fontWeight: "bold",
color: "white",
}}
>
FormCraft
</span>
</div>
<div
style={{
fontSize: "36px",
color: "rgba(255, 255, 255, 0.9)",
textAlign: "center",
maxWidth: "800px",
lineHeight: 1.4,
}}
>
Créez vos formulaires gratuitement
</div>
<div
style={{
fontSize: "22px",
color: "rgba(255, 255, 255, 0.7)",
textAlign: "center",
maxWidth: "700px",
marginTop: "20px",
lineHeight: 1.5,
}}
>
Alternative gratuite à Google Forms Sans inscription Partage instantané
</div>
<div
style={{
position: "absolute",
bottom: "40px",
fontSize: "18px",
color: "rgba(255, 255, 255, 0.5)",
}}
>
form.arthurp.fr
</div>
</div>
),
{
...size,
}
)
}
+208 -53
View File
@@ -1,65 +1,220 @@
import Image from "next/image";
import Link from "next/link"
import { Metadata } from "next"
export const metadata: Metadata = {
title: "FormCraft - Créez vos formulaires gratuitement | Alternative à Google Forms",
description:
"Créez des formulaires professionnels gratuitement en quelques clics avec FormCraft. Partagez-les via un lien unique, collectez et analysez les réponses instantanément. Sans inscription requise.",
alternates: {
canonical: "https://form.arthurp.fr",
},
}
const faqJsonLd = {
"@context": "https://schema.org",
"@type": "FAQPage",
mainEntity: [
{
"@type": "Question",
name: "Comment créer un formulaire avec FormCraft ?",
acceptedAnswer: {
"@type": "Answer",
text: "Ajoutez vos questions, personnalisez votre formulaire, puis copiez le lien unique généré automatiquement pour le partager.",
},
},
{
"@type": "Question",
name: "Est-ce que FormCraft est gratuit ?",
acceptedAnswer: {
"@type": "Answer",
text: "Oui, FormCraft est entièrement gratuit et ne nécessite aucune inscription.",
},
},
{
"@type": "Question",
name: "FormCraft est-il une alternative à Google Forms ?",
acceptedAnswer: {
"@type": "Answer",
text: "Oui, FormCraft est une alternative française et gratuite à Google Forms, sans inscription requise.",
},
},
],
}
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(faqJsonLd) }}
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
<div className="min-h-screen bg-linear-to-b from-white to-blue-50">
{/* Hero Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20" aria-labelledby="hero-title">
<div className="text-center">
<h1 id="hero-title" className="text-3xl sm:text-4xl md:text-5xl lg:text-6xl font-bold text-gray-900 tracking-tight">
Créez vos formulaires
<span className="text-blue-600"> facilement</span>
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<p className="mt-4 sm:mt-6 text-base sm:text-lg lg:text-xl text-gray-600 max-w-3xl mx-auto px-2">
Concevez des formulaires professionnels en quelques clics.
Partagez-les avec un simple lien et collectez les réponses instantanément.
<strong className="text-gray-900"> Sans inscription requise.</strong>
</p>
<div className="mt-8 sm:mt-10 flex flex-col sm:flex-row justify-center gap-3 sm:gap-4 px-4 sm:px-0">
<Link
href="/creer"
className="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-white bg-blue-600 rounded-xl hover:bg-blue-700 transition-colors shadow-lg shadow-blue-600/30"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Créer un formulaire
</Link>
<Link
href="/mes-formulaires"
className="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-gray-700 bg-white border-2 border-gray-200 rounded-xl hover:border-gray-300 hover:bg-gray-50 transition-colors"
>
Learning
</a>{" "}
center.
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
Voir mes formulaires
</Link>
</div>
</div>
</section>
{/* Features Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20" aria-labelledby="features-title">
<h2 id="features-title" className="text-2xl sm:text-3xl font-bold text-center text-gray-900 mb-8 sm:mb-12">
Pourquoi choisir FormCraft ?
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6 lg:gap-8">
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
}
title="Rapide et simple"
description="Créez votre formulaire en quelques minutes. Interface intuitive, sans courbe d'apprentissage."
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
}
title="Sécurisé"
description="Vos données sont protégées. Seul vous pouvez accéder aux réponses de vos formulaires."
/>
<FeatureCard
icon={
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
}
title="Partage facile"
description="Obtenez un lien unique pour partager votre formulaire. Collectez des réponses instantanément."
/>
</div>
</section>
{/* How it works */}
<section className="bg-white py-12 sm:py-16 lg:py-20" aria-labelledby="howto-title">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 id="howto-title" className="text-2xl sm:text-3xl font-bold text-center text-gray-900 mb-8 sm:mb-12">
Comment ça marche ?
</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 sm:gap-8">
<StepCard
number={1}
title="Créez"
description="Ajoutez vos questions et personnalisez votre formulaire"
/>
<StepCard
number={2}
title="Partagez"
description="Copiez le lien unique généré automatiquement"
/>
<StepCard
number={3}
title="Collectez"
description="Recevez les réponses en temps réel"
/>
<StepCard
number={4}
title="Analysez"
description="Consultez et exportez vos données facilement"
/>
</div>
</div>
</section>
{/* CTA Section */}
<section className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12 sm:py-16 lg:py-20" aria-labelledby="cta-title">
<div className="bg-blue-600 rounded-2xl sm:rounded-3xl p-6 sm:p-8 lg:p-12 text-center">
<h2 id="cta-title" className="text-xl sm:text-2xl lg:text-3xl font-bold text-white mb-3 sm:mb-4">
Prêt à créer votre premier formulaire ?
</h2>
<p className="text-blue-100 text-base sm:text-lg mb-6 sm:mb-8">
C&apos;est gratuit, rapide et sans inscription.
</p>
<Link
href="/creer"
className="inline-flex items-center justify-center px-8 py-4 text-lg font-semibold text-blue-600 bg-white rounded-xl hover:bg-blue-50 transition-colors"
>
Commencer maintenant
<svg className="w-5 h-5 ml-2" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</Link>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-gray-400 py-8 sm:py-12" role="contentinfo">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row justify-between items-center gap-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center" aria-hidden="true">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-xl font-bold text-white">FormCraft</span>
</div>
<p className="text-sm">
© {new Date().getFullYear()} FormCraft. Tous droits réservés.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</footer>
</div>
);
</>
)
}
function FeatureCard({ icon, title, description }: { icon: React.ReactNode; title: string; description: string }) {
return (
<article className="bg-white p-6 sm:p-8 rounded-2xl shadow-sm border border-gray-100 hover:shadow-md transition-shadow">
<div className="w-12 h-12 sm:w-14 sm:h-14 bg-blue-100 rounded-xl flex items-center justify-center text-blue-600 mb-4 sm:mb-6" aria-hidden="true">
{icon}
</div>
<h3 className="text-lg sm:text-xl font-semibold text-gray-900 mb-2 sm:mb-3">{title}</h3>
<p className="text-sm sm:text-base text-gray-600">{description}</p>
</article>
)
}
function StepCard({ number, title, description }: { number: number; title: string; description: string }) {
return (
<article className="text-center">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-blue-600 rounded-full flex items-center justify-center text-white text-lg sm:text-xl font-bold mx-auto mb-3 sm:mb-4" aria-hidden="true">
{number}
</div>
<h3 className="text-base sm:text-lg font-semibold text-gray-900 mb-1 sm:mb-2">Étape {number} : {title}</h3>
<p className="text-gray-600 text-xs sm:text-sm">{description}</p>
</article>
)
}
+22
View File
@@ -0,0 +1,22 @@
import { MetadataRoute } from 'next'
export default function robots(): MetadataRoute.Robots {
const baseUrl = 'https://form.arthurp.fr'
return {
rules: [
{
userAgent: '*',
allow: '/',
disallow: ['/api/', '/formulaire/*/resultats', '/formulaire/*/succes'],
},
{
userAgent: 'Googlebot',
allow: '/',
disallow: ['/api/', '/formulaire/*/resultats', '/formulaire/*/succes'],
},
],
sitemap: `${baseUrl}/sitemap.xml`,
host: baseUrl,
}
}
+27
View File
@@ -0,0 +1,27 @@
import { MetadataRoute } from 'next'
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = 'https://form.arthurp.fr'
const lastModified = new Date('2026-03-02')
return [
{
url: baseUrl,
lastModified,
changeFrequency: 'weekly',
priority: 1.0,
},
{
url: `${baseUrl}/creer`,
lastModified,
changeFrequency: 'monthly',
priority: 0.9,
},
{
url: `${baseUrl}/mes-formulaires`,
lastModified,
changeFrequency: 'daily',
priority: 0.7,
},
]
}
+185
View File
@@ -0,0 +1,185 @@
"use client"
import { FormField } from "@/types/form"
interface FieldEditorProps {
field: FormField
index: number
totalFields: number
onUpdate: (updates: Partial<FormField>) => void
onRemove: () => void
onMove: (direction: "up" | "down") => void
}
export default function FieldEditor({
field,
index,
totalFields,
onUpdate,
onRemove,
onMove,
}: FieldEditorProps) {
const hasOptions = field.type === "select" || field.type === "radio" || field.type === "checkbox"
const addOption = () => {
const newOptions = [...(field.options || []), `Option ${(field.options?.length || 0) + 1}`]
onUpdate({ options: newOptions })
}
const updateOption = (optionIndex: number, value: string) => {
const newOptions = [...(field.options || [])]
newOptions[optionIndex] = value
onUpdate({ options: newOptions })
}
const removeOption = (optionIndex: number) => {
const newOptions = (field.options || []).filter((_, i) => i !== optionIndex)
onUpdate({ options: newOptions })
}
const getFieldTypeLabel = (type: string): string => {
const labels: Record<string, string> = {
text: "Texte court",
textarea: "Texte long",
email: "Email",
number: "Nombre",
phone: "Téléphone",
date: "Date",
time: "Heure",
select: "Liste déroulante",
radio: "Choix unique",
checkbox: "Choix multiple",
}
return labels[type] || type
}
return (
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-4 sm:p-6">
<div className="flex items-start justify-between mb-3 sm:mb-4">
<div className="flex items-center space-x-3">
<span className="flex items-center justify-center w-7 h-7 sm:w-8 sm:h-8 bg-blue-100 text-blue-600 rounded-lg text-xs sm:text-sm font-semibold">
{index + 1}
</span>
<span className="text-xs sm:text-sm text-gray-500 bg-gray-100 px-2 sm:px-3 py-1 rounded-full">
{getFieldTypeLabel(field.type)}
</span>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => onMove("up")}
disabled={index === 0}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title="Monter"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
onClick={() => onMove("down")}
disabled={index === totalFields - 1}
className="p-2 text-gray-400 hover:text-gray-600 disabled:opacity-30 disabled:cursor-not-allowed"
title="Descendre"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
<button
onClick={onRemove}
className="p-2 text-red-400 hover:text-red-600"
title="Supprimer"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Question / Libellé *
</label>
<input
type="text"
value={field.label}
onChange={(e) => onUpdate({ label: e.target.value })}
placeholder="Ex: Quel est votre nom ?"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
{(field.type === "text" || field.type === "textarea" || field.type === "email" || field.type === "number" || field.type === "phone") && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Placeholder (optionnel)
</label>
<input
type="text"
value={field.placeholder || ""}
onChange={(e) => onUpdate({ placeholder: e.target.value })}
placeholder="Texte d'aide affiché dans le champ"
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
</div>
)}
{hasOptions && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Options
</label>
<div className="space-y-2">
{(field.options || []).map((option, optionIndex) => (
<div key={optionIndex} className="flex items-center space-x-2">
<input
type="text"
value={option}
onChange={(e) => updateOption(optionIndex, e.target.value)}
placeholder={`Option ${optionIndex + 1}`}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-colors"
/>
{(field.options?.length || 0) > 1 && (
<button
onClick={() => removeOption(optionIndex)}
className="p-2 text-red-400 hover:text-red-600"
title="Supprimer l'option"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
))}
<button
onClick={addOption}
className="flex items-center text-blue-600 hover:text-blue-700 text-sm font-medium mt-2"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Ajouter une option
</button>
</div>
</div>
)}
<div className="flex items-center">
<input
type="checkbox"
id={`required-${field.id}`}
checked={field.required}
onChange={(e) => onUpdate({ required: e.target.checked })}
className="w-4 h-4 text-blue-600 border-gray-300 rounded focus:ring-blue-500"
/>
<label htmlFor={`required-${field.id}`} className="ml-2 text-sm text-gray-700">
Champ obligatoire
</label>
</div>
</div>
</div>
)
}
+124
View File
@@ -0,0 +1,124 @@
"use client"
import Link from "next/link"
import { usePathname } from "next/navigation"
import { useState, useEffect } from "react"
export default function Header() {
const pathname = usePathname()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
// Fermer le menu quand on navigue
useEffect(() => {
setMobileMenuOpen(false)
}, [pathname])
// Empêcher le scroll quand le menu est ouvert
useEffect(() => {
if (mobileMenuOpen) {
document.body.style.overflow = "hidden"
} else {
document.body.style.overflow = ""
}
return () => {
document.body.style.overflow = ""
}
}, [mobileMenuOpen])
return (
<header className="fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<Link href="/" className="flex items-center space-x-2">
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center" aria-hidden="true">
<svg className="w-5 h-5 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<span className="text-xl font-bold text-gray-900">FormCraft</span>
</Link>
{/* Navigation desktop */}
<nav className="hidden sm:flex items-center space-x-4" aria-label="Navigation principale">
<Link
href="/mes-formulaires"
className={`px-4 py-2 rounded-lg text-sm font-medium transition-colors ${
pathname === '/mes-formulaires'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
Mes formulaires
</Link>
<Link
href="/creer"
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors"
>
Créer un formulaire
</Link>
</nav>
{/* Bouton hamburger mobile */}
<button
type="button"
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="sm:hidden inline-flex items-center justify-center p-2 rounded-lg text-gray-600 hover:text-gray-900 hover:bg-gray-100 transition-colors"
aria-expanded={mobileMenuOpen}
aria-controls="mobile-menu"
aria-label={mobileMenuOpen ? "Fermer le menu" : "Ouvrir le menu"}
>
{mobileMenuOpen ? (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
) : (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h16M4 18h16" />
</svg>
)}
</button>
</div>
</div>
{/* Menu mobile */}
{mobileMenuOpen && (
<>
{/* Overlay */}
<div
className="sm:hidden fixed inset-0 top-16 bg-black/20 z-40"
onClick={() => setMobileMenuOpen(false)}
aria-hidden="true"
/>
<nav
id="mobile-menu"
className="sm:hidden fixed left-0 right-0 top-16 bg-white border-b border-gray-200 z-50 shadow-lg"
aria-label="Navigation mobile"
>
<div className="px-4 py-4 space-y-2">
<Link
href="/mes-formulaires"
className={`block px-4 py-3 rounded-lg text-base font-medium transition-colors ${
pathname === '/mes-formulaires'
? 'bg-blue-100 text-blue-700'
: 'text-gray-600 hover:text-gray-900 hover:bg-gray-100'
}`}
>
Mes formulaires
</Link>
<Link
href="/creer"
className={`block px-4 py-3 rounded-lg text-base font-medium transition-colors ${
pathname === '/creer'
? 'bg-blue-700 text-white'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
Créer un formulaire
</Link>
</div>
</nav>
</>
)}
</header>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { LocalStorageForm } from '@/types/form'
export type { LocalStorageForm } from '@/types/form'
const STORAGE_KEY = 'formcraft_my_forms'
export function getMyForms(): LocalStorageForm[] {
if (typeof window === 'undefined') return []
const data = localStorage.getItem(STORAGE_KEY)
return data ? JSON.parse(data) : []
}
export function saveForm(form: LocalStorageForm): void {
if (typeof window === 'undefined') return
const forms = getMyForms()
const existingIndex = forms.findIndex(f => f.publicId === form.publicId)
if (existingIndex >= 0) {
forms[existingIndex] = form
} else {
forms.unshift(form)
}
localStorage.setItem(STORAGE_KEY, JSON.stringify(forms))
}
export function removeForm(publicId: string): void {
if (typeof window === 'undefined') return
const forms = getMyForms()
const filtered = forms.filter(f => f.publicId !== publicId)
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered))
}
export function getFormSecretKey(publicId: string): string | null {
const forms = getMyForms()
const form = forms.find(f => f.publicId === publicId)
return form?.secretKey || null
}
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
+47
View File
@@ -0,0 +1,47 @@
// Types pour les formulaires
export type FieldType =
| 'text'
| 'email'
| 'number'
| 'textarea'
| 'select'
| 'radio'
| 'checkbox'
| 'date'
| 'time'
| 'phone'
export interface FormField {
id: string
type: FieldType
label: string
placeholder?: string
required: boolean
options?: string[] // Pour select, radio, checkbox
}
export interface FormData {
id: string
title: string
description?: string
fields: FormField[]
secretKey: string
publicId: string
createdAt: string
updatedAt: string
}
export interface FormResponse {
id: string
formId: string
data: Record<string, string | string[]>
createdAt: string
}
export interface LocalStorageForm {
publicId: string
secretKey: string
title: string
createdAt: string
}