-
-
-
-
- To get started, edit the page.tsx file.
-
-
- Looking for a starting point or more instructions? Head over to{" "}
-
- Templates
- {" "}
- or the{" "}
-
- Learning
- {" "}
- center.
-
-
-
-
+
);
}
+
+export default function Home() {
+ return (
+
+ }>
+
+
+
+ );
+}
diff --git a/app/politique-confidentialite/page.tsx b/app/politique-confidentialite/page.tsx
new file mode 100644
index 0000000..722da79
--- /dev/null
+++ b/app/politique-confidentialite/page.tsx
@@ -0,0 +1,26 @@
+export default function PolitiqueConfidentialitePage() {
+ return (
+
+ Politique de confidentialite
+
+
+ Les informations saisies dans le formulaire (nom, email, message, projet, URL source) sont utilisees
+ uniquement pour traiter votre demande.
+
+ Les donnees sont stockees dans une base PostgreSQL auto-hebergee et ne sont pas revendues.
+
+
+ Base legale
+ Interet legitime: repondre aux demandes de contact et assurer le support des projets.
+
+
+ Conservation
+ Les messages sont conserves le temps necessaire au support et au suivi des echanges.
+
+
+ Droits
+ Vous pouvez demander acces, rectification ou suppression via contact@arthurp.fr.
+
+
+ );
+}
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..cdeba79
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,54 @@
+services:
+ postgres:
+ image: postgres:16-alpine
+ container_name: contact-postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_DB: ${POSTGRES_DB:-contact}
+ POSTGRES_USER: ${POSTGRES_USER:-contact}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-this-db-password}
+ ports:
+ - "5432:5432"
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+
+ contact-dev:
+ profiles: ["dev"]
+ build:
+ context: .
+ target: dev
+ container_name: contact-dev
+ ports:
+ - "3000:3000"
+ volumes:
+ - .:/app
+ - /app/node_modules
+ - /app/.next
+ environment:
+ NEXT_TELEMETRY_DISABLED: "1"
+ DATABASE_URL: ${DATABASE_URL_DOCKER:-postgresql://contact:change-this-db-password@postgres:5432/contact}
+ ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
+ ADMIN_PASSWORD: ${ADMIN_PASSWORD:-change-this-admin-password}
+ ADMIN_SESSION_SECRET: ${ADMIN_SESSION_SECRET:-change-this-session-secret}
+ depends_on:
+ - postgres
+
+ contact-prod:
+ profiles: ["prod"]
+ build:
+ context: .
+ target: runner
+ container_name: contact-prod
+ restart: unless-stopped
+ ports:
+ - "3018:3000"
+ env_file:
+ - .env
+ environment:
+ NEXT_TELEMETRY_DISABLED: "1"
+ DATABASE_URL: ${DATABASE_URL_DOCKER:-postgresql://contact:change-this-db-password@postgres:5432/contact}
+ depends_on:
+ - postgres
+
+volumes:
+ postgres-data:
diff --git a/lib/admin-auth.ts b/lib/admin-auth.ts
new file mode 100644
index 0000000..41b161a
--- /dev/null
+++ b/lib/admin-auth.ts
@@ -0,0 +1,98 @@
+import { createHmac, timingSafeEqual } from "crypto";
+import { cookies } from "next/headers";
+
+const COOKIE_NAME = "admin_session";
+const SESSION_TTL_SECONDS = 60 * 60 * 12;
+
+type SessionPayload = {
+ sub: string;
+ exp: number;
+};
+
+function getSecret() {
+ const secret = process.env.ADMIN_SESSION_SECRET;
+ if (!secret) {
+ throw new Error("ADMIN_SESSION_SECRET is missing.");
+ }
+ return secret;
+}
+
+function encodeBase64Url(value: string) {
+ return Buffer.from(value).toString("base64url");
+}
+
+function decodeBase64Url(value: string) {
+ return Buffer.from(value, "base64url").toString();
+}
+
+function sign(value: string) {
+ return createHmac("sha256", getSecret()).update(value).digest("base64url");
+}
+
+function safeEqual(a: string, b: string) {
+ const aBuffer = Buffer.from(a);
+ const bBuffer = Buffer.from(b);
+ if (aBuffer.length !== bBuffer.length) {
+ return false;
+ }
+ return timingSafeEqual(aBuffer, bBuffer);
+}
+
+export function createAdminToken(username: string) {
+ const payload: SessionPayload = {
+ sub: username,
+ exp: Math.floor(Date.now() / 1000) + SESSION_TTL_SECONDS,
+ };
+
+ const encodedPayload = encodeBase64Url(JSON.stringify(payload));
+ const signature = sign(encodedPayload);
+ return `${encodedPayload}.${signature}`;
+}
+
+function verifyToken(token: string): SessionPayload | null {
+ const [payloadPart, signaturePart] = token.split(".");
+ if (!payloadPart || !signaturePart) {
+ return null;
+ }
+
+ const expectedSignature = sign(payloadPart);
+ if (!safeEqual(signaturePart, expectedSignature)) {
+ return null;
+ }
+
+ try {
+ const payload = JSON.parse(decodeBase64Url(payloadPart)) as SessionPayload;
+ if (!payload?.sub || !payload?.exp) {
+ return null;
+ }
+
+ if (payload.exp < Math.floor(Date.now() / 1000)) {
+ return null;
+ }
+
+ return payload;
+ } catch {
+ return null;
+ }
+}
+
+export async function isAdminAuthenticated() {
+ const cookieStore = await cookies();
+ const token = cookieStore.get(COOKIE_NAME)?.value;
+ if (!token) {
+ return false;
+ }
+
+ return Boolean(verifyToken(token));
+}
+
+export function getAdminCredentials() {
+ return {
+ username: process.env.ADMIN_USERNAME || "admin",
+ password: process.env.ADMIN_PASSWORD || "change-me",
+ };
+}
+
+export function getAdminCookieName() {
+ return COOKIE_NAME;
+}
diff --git a/lib/contact.ts b/lib/contact.ts
new file mode 100644
index 0000000..dc40c4d
--- /dev/null
+++ b/lib/contact.ts
@@ -0,0 +1,51 @@
+import { z } from "zod";
+import { normalizeProjectParam, PROJECT_LABELS, PROJECT_VALUES } from "@/lib/sites";
+
+export const requestTypeValues = ["bug", "question", "suggestion", "other"] as const;
+
+export type RequestTypeValue = (typeof requestTypeValues)[number];
+
+export const projectValues = PROJECT_VALUES;
+
+export const REQUEST_TYPE_LABELS: Record
= {
+ bug: "Bug 🐛",
+ question: "Question ❓",
+ suggestion: "Suggestion 💡",
+ other: "Autre",
+};
+
+export const contactPayloadSchema = z.object({
+ name: z.string().trim().min(2, "Le nom est requis").max(80, "Nom trop long"),
+ email: z
+ .string()
+ .trim()
+ .min(1, "L'email est requis")
+ .email("Email invalide")
+ .max(160, "Email trop long"),
+ project: z.enum(projectValues, {
+ error: "Projet invalide",
+ }),
+ requestType: z.enum(requestTypeValues, {
+ error: "Type de demande invalide",
+ }),
+ message: z
+ .string()
+ .trim()
+ .min(10, "Le message doit contenir au moins 10 caracteres")
+ .max(3000, "Le message est trop long"),
+ honeypot: z
+ .string()
+ .optional()
+ .transform((value) => value ?? "")
+ .refine((value) => value.trim().length === 0, "Spam detecte"),
+ sourceUrl: z
+ .string()
+ .trim()
+ .max(500, "URL trop longue")
+ .optional()
+ .transform((value) => value ?? ""),
+});
+
+export type ContactPayload = z.infer;
+
+export { normalizeProjectParam, PROJECT_LABELS };
diff --git a/lib/db.ts b/lib/db.ts
new file mode 100644
index 0000000..670c236
--- /dev/null
+++ b/lib/db.ts
@@ -0,0 +1,175 @@
+import { Pool } from "pg";
+
+let pool: Pool | null = null;
+let schemaReady: Promise | null = null;
+
+function getDatabaseUrl() {
+ const url = process.env.DATABASE_URL;
+ if (!url) {
+ throw new Error("DATABASE_URL is not configured.");
+ }
+ return url;
+}
+
+function getPool() {
+ if (!pool) {
+ pool = new Pool({
+ connectionString: getDatabaseUrl(),
+ });
+ }
+ return pool;
+}
+
+async function ensureSchema() {
+ if (!schemaReady) {
+ schemaReady = (async () => {
+ const client = await getPool().connect();
+ try {
+ await client.query(`
+ CREATE TABLE IF NOT EXISTS contact_messages (
+ id BIGSERIAL PRIMARY KEY,
+ name TEXT NOT NULL,
+ email TEXT NOT NULL,
+ project TEXT NOT NULL,
+ request_type TEXT NOT NULL,
+ message TEXT NOT NULL,
+ source_url TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ replied_at TIMESTAMPTZ,
+ admin_reply TEXT,
+ status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'replied'))
+ );
+ `);
+
+ await client.query(`
+ ALTER TABLE contact_messages
+ ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending'
+ CHECK (status IN ('pending', 'replied'));
+ `);
+
+ await client.query(`
+ UPDATE contact_messages
+ SET status = CASE WHEN replied_at IS NULL THEN 'pending' ELSE 'replied' END
+ WHERE status IS DISTINCT FROM CASE WHEN replied_at IS NULL THEN 'pending' ELSE 'replied' END;
+ `);
+ } finally {
+ client.release();
+ }
+ })();
+ }
+
+ await schemaReady;
+}
+
+export type ContactMessageRow = {
+ id: number;
+ name: string;
+ email: string;
+ project: string;
+ request_type: string;
+ message: string;
+ source_url: string | null;
+ created_at: string;
+ replied_at: string | null;
+ admin_reply: string | null;
+ status: "pending" | "replied";
+};
+
+export async function createMessage(input: {
+ name: string;
+ email: string;
+ project: string;
+ requestType: string;
+ message: string;
+ sourceUrl?: string | null;
+}) {
+ await ensureSchema();
+ const result = await getPool().query(
+ `
+ INSERT INTO contact_messages (name, email, project, request_type, message, source_url)
+ VALUES ($1, $2, $3, $4, $5, $6)
+ RETURNING *
+ `,
+ [input.name, input.email, input.project, input.requestType, input.message, input.sourceUrl ?? null],
+ );
+
+ return result.rows[0];
+}
+
+export async function listMessages() {
+ await ensureSchema();
+ const result = await getPool().query(
+ `
+ SELECT *
+ FROM contact_messages
+ ORDER BY created_at DESC
+ `,
+ );
+
+ return result.rows;
+}
+
+export async function getMessageById(id: number) {
+ await ensureSchema();
+ const result = await getPool().query(
+ `
+ SELECT *
+ FROM contact_messages
+ WHERE id = $1
+ LIMIT 1
+ `,
+ [id],
+ );
+
+ return result.rows[0] ?? null;
+}
+
+export async function markMessageReply(id: number, reply: string) {
+ await ensureSchema();
+ const result = await getPool().query(
+ `
+ UPDATE contact_messages
+ SET admin_reply = $2,
+ replied_at = NOW(),
+ status = 'replied'
+ WHERE id = $1
+ RETURNING *
+ `,
+ [id, reply],
+ );
+
+ return result.rows[0] ?? null;
+}
+
+export async function setMessageStatus(id: number, status: "pending" | "replied") {
+ await ensureSchema();
+
+ const result = await getPool().query(
+ `
+ UPDATE contact_messages
+ SET status = $2,
+ replied_at = CASE WHEN $2 = 'replied' THEN COALESCE(replied_at, NOW()) ELSE NULL END,
+ admin_reply = CASE WHEN $2 = 'pending' THEN admin_reply ELSE admin_reply END
+ WHERE id = $1
+ RETURNING *
+ `,
+ [id, status],
+ );
+
+ return result.rows[0] ?? null;
+}
+
+export async function deleteMessage(id: number) {
+ await ensureSchema();
+
+ const result = await getPool().query<{ id: number }>(
+ `
+ DELETE FROM contact_messages
+ WHERE id = $1
+ RETURNING id
+ `,
+ [id],
+ );
+
+ return Boolean(result.rows[0]);
+}
diff --git a/lib/sites.ts b/lib/sites.ts
new file mode 100644
index 0000000..2397237
--- /dev/null
+++ b/lib/sites.ts
@@ -0,0 +1,60 @@
+export const projectSites = [
+ { value: "arthurp", label: "arthurp.fr", domain: "arthurp.fr", description: "Site principal" },
+ { value: "links", label: "links.arthurp.fr", domain: "links.arthurp.fr", description: "Liens centralises" },
+ { value: "qcu", label: "qcu.arthurp.fr", domain: "qcu.arthurp.fr", description: "Quiz et QCU" },
+ { value: "qrcode", label: "qrcode.arthurp.fr", domain: "qrcode.arthurp.fr", description: "Generateur de QR Code" },
+ { value: "lazybot", label: "lazybot.arthurp.fr", domain: "lazybot.arthurp.fr", description: "Bot et automatisations" },
+ { value: "learn", label: "learn.arthurp.fr", domain: "learn.arthurp.fr", description: "Ressources d'apprentissage" },
+ { value: "sudoku", label: "sudoku.arthurp.fr", domain: "sudoku.arthurp.fr", description: "Jeu Sudoku" },
+ { value: "reducelink", label: "reducelink.arthurp.fr", domain: "reducelink.arthurp.fr", description: "Reducteur de liens" },
+ { value: "clock", label: "clock.arthurp.fr", domain: "clock.arthurp.fr", description: "Horloge en ligne" },
+ { value: "form", label: "form.arthurp.fr", domain: "form.arthurp.fr", description: "Formulaires" },
+ { value: "pomodoro", label: "pomodoro.arthurp.fr", domain: "pomodoro.arthurp.fr", description: "Timer pomodoro" },
+ { value: "visio", label: "visio.arthurp.fr", domain: "visio.arthurp.fr", description: "Visioconference" },
+ { value: "doudou", label: "doudou.arthurp.fr", domain: "doudou.arthurp.fr", description: "Projet Doudou" },
+ { value: "portfolio", label: "portfolio.arthurp.fr", domain: "portfolio.arthurp.fr", description: "Portfolio" },
+ { value: "moon", label: "moon.arthurp.fr", domain: "moon.arthurp.fr", description: "Projet Moon" },
+ { value: "calculatrice", label: "calculatrice.arthurp.fr", domain: "calculatrice.arthurp.fr", description: "Calculatrice" },
+ { value: "chrono", label: "chrono.arthurp.fr", domain: "chrono.arthurp.fr", description: "Chronometre" },
+ { value: "blocnote", label: "blocnote.arthurp.fr", domain: "blocnote.arthurp.fr", description: "Bloc-notes" },
+ { value: "imprimersudoku", label: "imprimersudoku.arthurp.fr", domain: "imprimersudoku.arthurp.fr", description: "Impression Sudoku" },
+ { value: "other", label: "Autre", domain: "autre", description: "Autre demande" },
+] as const;
+
+export type ProjectSite = (typeof projectSites)[number];
+export type ProjectValue = ProjectSite["value"];
+
+export const PROJECT_VALUES = projectSites.map((site) => site.value) as [ProjectValue, ...ProjectValue[]];
+
+export const PROJECT_LABELS: Record = Object.fromEntries(
+ projectSites.map((site) => [site.value, site.label]),
+) as Record;
+
+export const PROJECT_DESCRIPTIONS: Record = Object.fromEntries(
+ projectSites.map((site) => [site.value, site.description]),
+) as Record;
+
+export const footerSites = projectSites.filter((site) => site.value !== "other");
+
+export function normalizeProjectParam(rawProject: string | null): ProjectValue | null {
+ if (!rawProject) {
+ return null;
+ }
+
+ const cleaned = rawProject.trim().toLowerCase();
+ const byValue = projectSites.find((site) => site.value === cleaned);
+ if (byValue) {
+ return byValue.value;
+ }
+
+ const byDomain = projectSites.find((site) => site.domain === cleaned);
+ if (byDomain) {
+ return byDomain.value;
+ }
+
+ if (cleaned === "qr-code" || cleaned === "qr" || cleaned === "qr-code-generator") {
+ return "qrcode";
+ }
+
+ return null;
+}
diff --git a/next.config.ts b/next.config.ts
index e9ffa30..68a6c64 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -1,7 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
- /* config options here */
+ output: "standalone",
};
export default nextConfig;
diff --git a/package-lock.json b/package-lock.json
index 056da26..cdfa7d1 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,17 +8,23 @@
"name": "contact",
"version": "0.1.0",
"dependencies": {
- "next": "16.2.1",
+ "lucide-react": "^1.7.0",
+ "next": "16.2.2",
+ "nodemailer": "^8.0.4",
+ "pg": "^8.20.0",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/nodemailer": "^7.0.11",
+ "@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.2.1",
+ "eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
@@ -1036,15 +1042,15 @@
}
},
"node_modules/@next/env": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.1.tgz",
- "integrity": "sha512-n8P/HCkIWW+gVal2Z8XqXJ6aB3J0tuM29OcHpCsobWlChH/SITBs1DFBk/HajgrwDkqqBXPbuUuzgDvUekREPg==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz",
+ "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.1.tgz",
- "integrity": "sha512-r0epZGo24eT4g08jJlg2OEryBphXqO8aL18oajoTKLzHJ6jVr6P6FI58DLMug04MwD3j8Fj0YK0slyzneKVyzA==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.2.tgz",
+ "integrity": "sha512-IOPbWzDQ+76AtjZioaCjpIY72xNSDMnarZ2GMQ4wjNLvnJEJHqxQwGFhgnIWLV9klb4g/+amg88Tk5OXVpyLTw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1052,9 +1058,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.1.tgz",
- "integrity": "sha512-BwZ8w8YTaSEr2HIuXLMLxIdElNMPvY9fLqb20LX9A9OMGtJilhHLbCL3ggyd0TwjmMcTxi0XXt+ur1vWUoxj2Q==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz",
+ "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==",
"cpu": [
"arm64"
],
@@ -1068,9 +1074,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.1.tgz",
- "integrity": "sha512-/vrcE6iQSJq3uL3VGVHiXeaKbn8Es10DGTGRJnRZlkNQQk3kaNtAJg8Y6xuAlrx/6INKVjkfi5rY0iEXorZ6uA==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz",
+ "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==",
"cpu": [
"x64"
],
@@ -1084,9 +1090,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.1.tgz",
- "integrity": "sha512-uLn+0BK+C31LTVbQ/QU+UaVrV0rRSJQ8RfniQAHPghDdgE+SlroYqcmFnO5iNjNfVWCyKZHYrs3Nl0mUzWxbBw==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz",
+ "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==",
"cpu": [
"arm64"
],
@@ -1100,9 +1106,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.1.tgz",
- "integrity": "sha512-ssKq6iMRnHdnycGp9hCuGnXJZ0YPr4/wNwrfE5DbmvEcgl9+yv97/Kq3TPVDfYome1SW5geciLB9aiEqKXQjlQ==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz",
+ "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==",
"cpu": [
"arm64"
],
@@ -1116,9 +1122,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.1.tgz",
- "integrity": "sha512-HQm7SrHRELJ30T1TSmT706IWovFFSRGxfgUkyWJZF/RKBMdbdRWJuFrcpDdE5vy9UXjFOx6L3mRdqH04Mmx0hg==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz",
+ "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==",
"cpu": [
"x64"
],
@@ -1132,9 +1138,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.1.tgz",
- "integrity": "sha512-aV2iUaC/5HGEpbBkE+4B8aHIudoOy5DYekAKOMSHoIYQ66y/wIVeaRx8MS2ZMdxe/HIXlMho4ubdZs/J8441Tg==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz",
+ "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==",
"cpu": [
"x64"
],
@@ -1148,9 +1154,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.1.tgz",
- "integrity": "sha512-IXdNgiDHaSk0ZUJ+xp0OQTdTgnpx1RCfRTalhn3cjOP+IddTMINwA7DXZrwTmGDO8SUr5q2hdP/du4DcrB1GxA==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz",
+ "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==",
"cpu": [
"arm64"
],
@@ -1164,9 +1170,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.1.tgz",
- "integrity": "sha512-qvU+3a39Hay+ieIztkGSbF7+mccbbg1Tk25hc4JDylf8IHjYmY/Zm64Qq1602yPyQqvie+vf5T/uPwNxDNIoeg==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz",
+ "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==",
"cpu": [
"x64"
],
@@ -1556,6 +1562,28 @@
"undici-types": "~6.21.0"
}
},
+ "node_modules/@types/nodemailer": {
+ "version": "7.0.11",
+ "resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
+ "integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*"
+ }
+ },
+ "node_modules/@types/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/node": "*",
+ "pg-protocol": "*",
+ "pg-types": "^2.2.0"
+ }
+ },
"node_modules/@types/react": {
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
@@ -2568,9 +2596,9 @@
}
},
"node_modules/caniuse-lite": {
- "version": "1.0.30001782",
- "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001782.tgz",
- "integrity": "sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==",
+ "version": "1.0.30001784",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001784.tgz",
+ "integrity": "sha512-WU346nBTklUV9YfUl60fqRbU5ZqyXlqvo1SgigE1OAXK5bFL8LL9q1K7aap3N739l4BvNqnkm3YrGHiY9sfUQw==",
"funding": [
{
"type": "opencollective",
@@ -2827,9 +2855,9 @@
}
},
"node_modules/electron-to-chromium": {
- "version": "1.5.329",
- "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.329.tgz",
- "integrity": "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ==",
+ "version": "1.5.330",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.330.tgz",
+ "integrity": "sha512-jFNydB5kFtYUobh4IkWUnXeyDbjf/r9gcUEXe1xcrcUxIGfTdzPXA+ld6zBRbwvgIGVzDll/LTIiDztEtckSnA==",
"dev": true,
"license": "ISC"
},
@@ -3117,13 +3145,13 @@
}
},
"node_modules/eslint-config-next": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.1.tgz",
- "integrity": "sha512-qhabwjQZ1Mk53XzXvmogf8KQ0tG0CQXF0CZ56+2/lVhmObgmaqj7x5A1DSrWdZd3kwI7GTPGUjFne+krRxYmFg==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.2.tgz",
+ "integrity": "sha512-6VlvEhwoug2JpVgjZDhyXrJXUEuPY++TddzIpTaIRvlvlXXFgvQUtm3+Zr84IjFm0lXtJt73w19JA08tOaZVwg==",
"dev": true,
"license": "MIT",
"dependencies": {
- "@next/eslint-plugin-next": "16.2.1",
+ "@next/eslint-plugin-next": "16.2.2",
"eslint-import-resolver-node": "^0.3.6",
"eslint-import-resolver-typescript": "^3.5.2",
"eslint-plugin-import": "^2.32.0",
@@ -3247,6 +3275,7 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4875,6 +4904,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lucide-react": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz",
+ "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==",
+ "license": "ISC",
+ "peerDependencies": {
+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4991,12 +5029,12 @@
"license": "MIT"
},
"node_modules/next": {
- "version": "16.2.1",
- "resolved": "https://registry.npmjs.org/next/-/next-16.2.1.tgz",
- "integrity": "sha512-VaChzNL7o9rbfdt60HUj8tev4m6d7iC1igAy157526+cJlXOQu5LzsBXNT+xaJnTP/k+utSX5vMv7m0G+zKH+Q==",
+ "version": "16.2.2",
+ "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz",
+ "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==",
"license": "MIT",
"dependencies": {
- "@next/env": "16.2.1",
+ "@next/env": "16.2.2",
"@swc/helpers": "0.5.15",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
@@ -5010,14 +5048,14 @@
"node": ">=20.9.0"
},
"optionalDependencies": {
- "@next/swc-darwin-arm64": "16.2.1",
- "@next/swc-darwin-x64": "16.2.1",
- "@next/swc-linux-arm64-gnu": "16.2.1",
- "@next/swc-linux-arm64-musl": "16.2.1",
- "@next/swc-linux-x64-gnu": "16.2.1",
- "@next/swc-linux-x64-musl": "16.2.1",
- "@next/swc-win32-arm64-msvc": "16.2.1",
- "@next/swc-win32-x64-msvc": "16.2.1",
+ "@next/swc-darwin-arm64": "16.2.2",
+ "@next/swc-darwin-x64": "16.2.2",
+ "@next/swc-linux-arm64-gnu": "16.2.2",
+ "@next/swc-linux-arm64-musl": "16.2.2",
+ "@next/swc-linux-x64-gnu": "16.2.2",
+ "@next/swc-linux-x64-musl": "16.2.2",
+ "@next/swc-win32-arm64-msvc": "16.2.2",
+ "@next/swc-win32-x64-msvc": "16.2.2",
"sharp": "^0.34.5"
},
"peerDependencies": {
@@ -5097,6 +5135,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/nodemailer": {
+ "version": "8.0.4",
+ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.4.tgz",
+ "integrity": "sha512-k+jf6N8PfQJ0Fe8ZhJlgqU5qJU44Lpvp2yvidH3vp1lPnVQMgi4yEEMPXg5eJS1gFIJTVq1NHBk7Ia9ARdSBdQ==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5328,6 +5375,96 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pg": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+ "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "pg-connection-string": "^2.12.0",
+ "pg-pool": "^3.13.0",
+ "pg-protocol": "^1.13.0",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.3.0"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+ "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.12.0",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+ "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.13.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+ "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.13.0",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+ "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -5386,6 +5523,45 @@
"node": "^10 || ^12 || >=14"
}
},
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
"node_modules/prelude-ls": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
@@ -5875,6 +6051,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6556,6 +6741,15 @@
"node": ">=0.10.0"
}
},
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -6580,7 +6774,6 @@
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
- "dev": true,
"license": "MIT",
"peer": true,
"funding": {
diff --git a/package.json b/package.json
index c150862..8f4e484 100644
--- a/package.json
+++ b/package.json
@@ -9,17 +9,23 @@
"lint": "eslint"
},
"dependencies": {
- "next": "16.2.1",
+ "lucide-react": "^1.7.0",
+ "next": "16.2.2",
+ "nodemailer": "^8.0.4",
+ "pg": "^8.20.0",
"react": "19.2.4",
- "react-dom": "19.2.4"
+ "react-dom": "19.2.4",
+ "zod": "^4.3.6"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
+ "@types/nodemailer": "^7.0.11",
+ "@types/pg": "^8.20.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
- "eslint-config-next": "16.2.1",
+ "eslint-config-next": "16.2.2",
"tailwindcss": "^4",
"typescript": "^5"
}
diff --git a/public/file.svg b/public/file.svg
deleted file mode 100644
index 004145c..0000000
--- a/public/file.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/globe.svg b/public/globe.svg
deleted file mode 100644
index 567f17b..0000000
--- a/public/globe.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/next.svg b/public/next.svg
deleted file mode 100644
index 5174b28..0000000
--- a/public/next.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/vercel.svg b/public/vercel.svg
deleted file mode 100644
index 7705396..0000000
--- a/public/vercel.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file
diff --git a/public/window.svg b/public/window.svg
deleted file mode 100644
index b2b2a44..0000000
--- a/public/window.svg
+++ /dev/null
@@ -1 +0,0 @@
-
\ No newline at end of file