feat: add contact page with form handling and validation
- Implemented a contact page with a form for user inquiries. - Added validation for form fields using Zod schema. - Integrated PostgreSQL database for storing contact messages. - Created necessary API endpoints for form submission. - Added admin authentication and session management. - Developed CGU, cookies policy, privacy policy, and legal mentions pages. - Set up Docker configuration for PostgreSQL and application services. - Enhanced UI with responsive design and accessibility features.
@@ -0,0 +1,12 @@
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
Dockerfile*
|
||||
README.md
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -36,6 +36,9 @@ yarn-error.log*
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# editor local settings
|
||||
.vscode/
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
FROM node:22-alpine AS base
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS deps
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM deps AS dev
|
||||
COPY . .
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "dev", "--", "--hostname", "0.0.0.0", "--port", "3000"]
|
||||
|
||||
FROM deps AS builder
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM base AS runner
|
||||
ENV NODE_ENV=production
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
ENV PORT=3000
|
||||
COPY --from=builder /app/.next/standalone ./
|
||||
COPY --from=builder /app/.next/static ./.next/static
|
||||
COPY --from=builder /app/public ./public
|
||||
EXPOSE 3000
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,36 +1,112 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Contact ArthurP
|
||||
|
||||
## Getting Started
|
||||
Page de contact centralisee pour les projets ArthurP avec stockage PostgreSQL et espace admin.
|
||||
|
||||
First, run the development server:
|
||||
Site officiel: `https://contact.arthurp.fr`
|
||||
|
||||
## Stack
|
||||
|
||||
- Next.js 16 (App Router)
|
||||
- React 19
|
||||
- Tailwind CSS 4
|
||||
- API route `app/api/contact/route.ts`
|
||||
- Validation partagee avec Zod
|
||||
- PostgreSQL (stockage des messages)
|
||||
- Notifications via Nodemailer et Discord webhook (optionnel)
|
||||
- Dashboard admin avec authentification par cookie de session
|
||||
|
||||
## Sites supportes
|
||||
|
||||
- arthurp.fr
|
||||
- links.arthurp.fr
|
||||
- qcu.arthurp.fr
|
||||
- qrcode.arthurp.fr
|
||||
- lazybot.arthurp.fr
|
||||
- learn.arthurp.fr
|
||||
- sudoku.arthurp.fr
|
||||
- reducelink.arthurp.fr
|
||||
- clock.arthurp.fr
|
||||
- form.arthurp.fr
|
||||
- pomodoro.arthurp.fr
|
||||
- visio.arthurp.fr
|
||||
- doudou.arthurp.fr
|
||||
- portfolio.arthurp.fr
|
||||
- moon.arthurp.fr
|
||||
- calculatrice.arthurp.fr
|
||||
- chrono.arthurp.fr
|
||||
- blocnote.arthurp.fr
|
||||
- imprimersudoku.arthurp.fr
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
Copier `.env.example` vers `.env` puis completer:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Variables principales:
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
- `CONTACT_TO_EMAIL` adresse recevant les messages
|
||||
- `CONTACT_FROM_EMAIL` expediteur technique
|
||||
- `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS` pour l'envoi email
|
||||
- `DISCORD_WEBHOOK_URL` (optionnel)
|
||||
- `DATABASE_URL` URL PostgreSQL
|
||||
- `ADMIN_USERNAME`, `ADMIN_PASSWORD` credentials admin
|
||||
- `ADMIN_SESSION_SECRET` secret de signature de session admin
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
## Lancement en local
|
||||
|
||||
## Learn More
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
Accessible sur `http://localhost:3000`.
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
Dashboard admin: `http://localhost:3000/admin`
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
Exemples de liens:
|
||||
|
||||
## Deploy on Vercel
|
||||
- `http://localhost:3000?project=lazybot`
|
||||
- `http://localhost:3000?project=qrcode`
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
## Docker
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
Le projet fournit un Dockerfile multi-stage + Compose avec profils `dev` et `prod`.
|
||||
|
||||
### Developpement (hot reload + PostgreSQL)
|
||||
|
||||
```bash
|
||||
docker compose --profile dev up --build
|
||||
```
|
||||
|
||||
### Production (app + PostgreSQL)
|
||||
|
||||
```bash
|
||||
docker compose --profile prod up --build -d
|
||||
```
|
||||
|
||||
Le build production utilise `output: "standalone"` pour une image plus propre et legere.
|
||||
|
||||
## Pages legales
|
||||
|
||||
- `/mentions-legales`
|
||||
- `/politique-confidentialite`
|
||||
- `/cgu`
|
||||
- `/cookies`
|
||||
|
||||
## Verification qualite
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Securite deja en place
|
||||
|
||||
- Validation client + serveur
|
||||
- Honeypot anti-spam
|
||||
- Limitation de requetes basique par IP
|
||||
- Secrets conserves via variables d'environnement
|
||||
- Session admin en cookie HttpOnly
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import AdminPanelClient from "./panel-client";
|
||||
|
||||
export default function AdminPage() {
|
||||
return <AdminPanelClient />;
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
type AdminMessage = {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
project: string;
|
||||
requestType: string;
|
||||
message: string;
|
||||
createdAt: string;
|
||||
repliedAt: string | null;
|
||||
adminReply: string | null;
|
||||
status: "pending" | "replied";
|
||||
};
|
||||
|
||||
type FilterState = "all" | "pending" | "replied";
|
||||
|
||||
export default function AdminPanelClient() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [login, setLogin] = useState({ username: "", password: "" });
|
||||
const [messages, setMessages] = useState<AdminMessage[]>([]);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [replyDrafts, setReplyDrafts] = useState<Record<number, string>>({});
|
||||
const [replyingId, setReplyingId] = useState<number | null>(null);
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null);
|
||||
const [statusUpdatingId, setStatusUpdatingId] = useState<number | null>(null);
|
||||
const [activeFilter, setActiveFilter] = useState<FilterState>("all");
|
||||
|
||||
async function checkSession() {
|
||||
setIsChecking(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/me");
|
||||
setIsAuthenticated(response.ok);
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
async function loadMessages() {
|
||||
setLoadingMessages(true);
|
||||
try {
|
||||
const response = await fetch("/api/admin/messages");
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) {
|
||||
setIsAuthenticated(false);
|
||||
return;
|
||||
}
|
||||
throw new Error("Impossible de charger les messages.");
|
||||
}
|
||||
|
||||
const data = (await response.json()) as { messages: AdminMessage[] };
|
||||
setMessages(data.messages);
|
||||
const initialDrafts = Object.fromEntries(
|
||||
data.messages.map((item) => [item.id, item.adminReply ?? ""]),
|
||||
) as Record<number, string>;
|
||||
setReplyDrafts(initialDrafts);
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : "Erreur de chargement.");
|
||||
} finally {
|
||||
setLoadingMessages(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadMessages();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
async function submitLogin(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setAuthError(null);
|
||||
|
||||
const response = await fetch("/api/admin/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(login),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
setAuthError(payload?.message ?? "Connexion impossible.");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAuthenticated(true);
|
||||
setLogin({ username: "", password: "" });
|
||||
await loadMessages();
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await fetch("/api/admin/logout", { method: "POST" });
|
||||
setIsAuthenticated(false);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
async function sendReply(id: number) {
|
||||
const reply = (replyDrafts[id] || "").trim();
|
||||
if (reply.length < 5) {
|
||||
setAuthError("La reponse doit contenir au moins 5 caracteres.");
|
||||
return;
|
||||
}
|
||||
|
||||
setReplyingId(id);
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/messages/${id}/reply`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reply }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? "Impossible d'envoyer la reponse.");
|
||||
}
|
||||
|
||||
await loadMessages();
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : "Erreur d'envoi.");
|
||||
} finally {
|
||||
setReplyingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStatus(item: AdminMessage) {
|
||||
const nextStatus = item.status === "pending" ? "replied" : "pending";
|
||||
setStatusUpdatingId(item.id);
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/messages/${item.id}/status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ status: nextStatus }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? "Impossible de changer le statut.");
|
||||
}
|
||||
|
||||
await loadMessages();
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : "Erreur de statut.");
|
||||
} finally {
|
||||
setStatusUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeMessage(id: number) {
|
||||
const confirmed = window.confirm("Supprimer ce message ? Cette action est irreversible.");
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDeletingId(id);
|
||||
setAuthError(null);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/admin/messages/${id}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as { message?: string } | null;
|
||||
throw new Error(payload?.message ?? "Suppression impossible.");
|
||||
}
|
||||
|
||||
await loadMessages();
|
||||
} catch (error) {
|
||||
setAuthError(error instanceof Error ? error.message : "Erreur de suppression.");
|
||||
} finally {
|
||||
setDeletingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = messages.length;
|
||||
const replied = messages.filter((item) => item.status === "replied").length;
|
||||
return { total, replied, pending: total - replied };
|
||||
}, [messages]);
|
||||
|
||||
const filteredMessages = useMemo(() => {
|
||||
if (activeFilter === "all") {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.filter((item) => item.status === activeFilter);
|
||||
}, [messages, activeFilter]);
|
||||
|
||||
if (isChecking) {
|
||||
return <main className="mx-auto max-w-5xl px-4 py-10">Verification de session...</main>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<main className="mx-auto max-w-md px-4 py-10">
|
||||
<section className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="mb-4">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex rounded-xl border border-slate-300 px-3 py-2 text-sm hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
Retour a l'accueil
|
||||
</Link>
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">Connexion admin</h1>
|
||||
<p className="mt-2 text-sm text-slate-600 dark:text-slate-300">
|
||||
Connecte-toi pour voir les messages et repondre depuis le tableau de bord.
|
||||
</p>
|
||||
<form className="mt-5 space-y-4" onSubmit={submitLogin}>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Identifiant
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-950"
|
||||
value={login.username}
|
||||
onChange={(event) => setLogin((prev) => ({ ...prev, username: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Mot de passe
|
||||
<input
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 dark:border-slate-700 dark:bg-slate-950"
|
||||
type="password"
|
||||
value={login.password}
|
||||
onChange={(event) => setLogin((prev) => ({ ...prev, password: event.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 dark:bg-cyan-500 dark:text-slate-950"
|
||||
>
|
||||
Se connecter
|
||||
</button>
|
||||
</form>
|
||||
{authError ? <p className="mt-3 text-sm text-rose-600">{authError}</p> : null}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-5xl space-y-6 px-4 py-10">
|
||||
<section className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900 dark:text-white">Espace admin</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300">Gestion des messages de contact</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl border border-slate-300 px-3 py-2 text-sm hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
Retour a l'accueil
|
||||
</Link>
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2 text-sm hover:bg-slate-100 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
Se deconnecter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<button
|
||||
onClick={() => setActiveFilter("all")}
|
||||
className={[
|
||||
"rounded-full px-3 py-1",
|
||||
activeFilter === "all" ? "bg-slate-900 text-white dark:bg-cyan-500 dark:text-slate-950" : "bg-slate-100 dark:bg-slate-800",
|
||||
].join(" ")}
|
||||
>
|
||||
Total: {stats.total}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter("pending")}
|
||||
className={[
|
||||
"rounded-full px-3 py-1",
|
||||
activeFilter === "pending"
|
||||
? "bg-amber-600 text-white dark:bg-amber-500 dark:text-slate-950"
|
||||
: "bg-amber-100 text-amber-900 dark:bg-amber-500/20 dark:text-amber-200",
|
||||
].join(" ")}
|
||||
>
|
||||
En attente: {stats.pending}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveFilter("replied")}
|
||||
className={[
|
||||
"rounded-full px-3 py-1",
|
||||
activeFilter === "replied"
|
||||
? "bg-emerald-600 text-white dark:bg-emerald-500 dark:text-slate-950"
|
||||
: "bg-emerald-100 text-emerald-900 dark:bg-emerald-500/20 dark:text-emerald-200",
|
||||
].join(" ")}
|
||||
>
|
||||
Repondus: {stats.replied}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{loadingMessages ? (
|
||||
<section className="rounded-3xl border border-slate-200 bg-white/90 p-6 dark:border-slate-800 dark:bg-slate-900/80">Chargement...</section>
|
||||
) : null}
|
||||
|
||||
{filteredMessages.map((item) => (
|
||||
<article key={item.id} className="rounded-3xl border border-slate-200 bg-white/90 p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900/80">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">#{item.id} - {new Date(item.createdAt).toLocaleString("fr-FR")}</p>
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">{item.name} ({item.email})</h2>
|
||||
<p className="text-sm text-cyan-700 dark:text-cyan-300">{item.project} - {item.requestType}</p>
|
||||
</div>
|
||||
{item.status === "replied" ? (
|
||||
<span className="rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-800 dark:bg-emerald-500/20 dark:text-emerald-200">
|
||||
Repondu
|
||||
</span>
|
||||
) : (
|
||||
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800 dark:bg-amber-500/20 dark:text-amber-200">
|
||||
En attente
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-4 whitespace-pre-wrap rounded-xl border border-slate-200 bg-slate-50 p-3 text-sm dark:border-slate-700 dark:bg-slate-950/50">
|
||||
{item.message}
|
||||
</p>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Reponse admin
|
||||
<textarea
|
||||
className="mt-1 w-full rounded-xl border border-slate-300 px-3 py-2 text-sm dark:border-slate-700 dark:bg-slate-950"
|
||||
rows={4}
|
||||
value={replyDrafts[item.id] ?? ""}
|
||||
onChange={(event) =>
|
||||
setReplyDrafts((prev) => ({ ...prev, [item.id]: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={() => sendReply(item.id)}
|
||||
disabled={replyingId === item.id}
|
||||
className="rounded-xl bg-slate-900 px-4 py-2 text-sm font-semibold text-white hover:bg-slate-700 disabled:opacity-60 dark:bg-cyan-500 dark:text-slate-950"
|
||||
>
|
||||
{replyingId === item.id ? "Envoi..." : "Envoyer la reponse"}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
<button
|
||||
onClick={() => toggleStatus(item)}
|
||||
disabled={statusUpdatingId === item.id}
|
||||
className="rounded-xl border border-slate-300 px-3 py-2 text-sm hover:bg-slate-100 disabled:opacity-60 dark:border-slate-700 dark:hover:bg-slate-800"
|
||||
>
|
||||
{statusUpdatingId === item.id
|
||||
? "Mise a jour..."
|
||||
: item.status === "pending"
|
||||
? "Passer en repondu"
|
||||
: "Repasser en attente"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => removeMessage(item.id)}
|
||||
disabled={deletingId === item.id}
|
||||
className="rounded-xl border border-rose-300 px-3 py-2 text-sm text-rose-700 hover:bg-rose-50 disabled:opacity-60 dark:border-rose-500/40 dark:text-rose-300 dark:hover:bg-rose-500/10"
|
||||
>
|
||||
{deletingId === item.id ? "Suppression..." : "Supprimer"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
|
||||
{!loadingMessages && filteredMessages.length === 0 ? (
|
||||
<section className="rounded-3xl border border-slate-200 bg-white/90 p-6 text-sm text-slate-600 dark:border-slate-800 dark:bg-slate-900/80 dark:text-slate-300">
|
||||
Aucun message dans ce filtre.
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{authError ? <p className="text-sm text-rose-600 dark:text-rose-400">{authError}</p> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { createAdminToken, getAdminCookieName, getAdminCredentials } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = (await request.json().catch(() => null)) as
|
||||
| { username?: string; password?: string }
|
||||
| null;
|
||||
|
||||
if (!body?.username || !body?.password) {
|
||||
return NextResponse.json({ message: "Identifiants manquants." }, { status: 400 });
|
||||
}
|
||||
|
||||
const credentials = getAdminCredentials();
|
||||
if (body.username !== credentials.username || body.password !== credentials.password) {
|
||||
return NextResponse.json({ message: "Identifiants invalides." }, { status: 401 });
|
||||
}
|
||||
|
||||
const token = createAdminToken(body.username);
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set(getAdminCookieName(), token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 12,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getAdminCookieName } from "@/lib/admin-auth";
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ ok: true });
|
||||
response.cookies.set(getAdminCookieName(), "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
|
||||
export async function GET() {
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { getMessageById, markMessageReply } from "@/lib/db";
|
||||
|
||||
async function sendReplyEmail(input: { to: string; reply: string; senderName: string; originalMessage: string }) {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpPort = Number(process.env.SMTP_PORT || "587");
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass) {
|
||||
throw new Error("SMTP non configure pour les reponses admin.");
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number.isNaN(smtpPort) ? 587 : smtpPort,
|
||||
secure: smtpPort === 465,
|
||||
auth: { user: smtpUser, pass: smtpPass },
|
||||
});
|
||||
|
||||
const fromEmail = process.env.CONTACT_FROM_EMAIL || smtpUser;
|
||||
const escapedOriginal = input.originalMessage
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escapedReply = input.reply
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: input.to,
|
||||
subject: "Reponse a votre message",
|
||||
text: [
|
||||
`Bonjour ${input.senderName},`,
|
||||
"",
|
||||
"Merci pour votre message. Voici ma reponse:",
|
||||
"",
|
||||
input.reply,
|
||||
"",
|
||||
"---------------------------",
|
||||
"Votre message initial:",
|
||||
input.originalMessage,
|
||||
"---------------------------",
|
||||
"",
|
||||
"ArthurP",
|
||||
].join("\n"),
|
||||
html: `
|
||||
<div style="font-family: Inter, Segoe UI, Arial, sans-serif; color: #0f172a; line-height: 1.6;">
|
||||
<h2 style="margin: 0 0 12px;">Reponse a votre message</h2>
|
||||
<p style="margin: 0 0 16px;">Bonjour ${input.senderName},</p>
|
||||
<p style="margin: 0 0 8px;">Merci pour votre message. Voici ma reponse:</p>
|
||||
<div style="margin: 0 0 20px; padding: 12px 14px; background: #ecfeff; border: 1px solid #a5f3fc; border-radius: 10px; white-space: pre-wrap;">${escapedReply}</div>
|
||||
<p style="margin: 0 0 8px; font-weight: 600;">Votre message initial:</p>
|
||||
<div style="margin: 0; padding: 12px 14px; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; white-space: pre-wrap;">${escapedOriginal}</div>
|
||||
<p style="margin: 20px 0 0; font-size: 13px; color: #475569;">ArthurP</p>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const messageId = Number(id);
|
||||
if (!Number.isInteger(messageId) || messageId <= 0) {
|
||||
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as { reply?: string } | null;
|
||||
const reply = body?.reply?.trim() || "";
|
||||
if (reply.length < 5) {
|
||||
return NextResponse.json({ message: "Reponse trop courte." }, { status: 400 });
|
||||
}
|
||||
|
||||
const existing = await getMessageById(messageId);
|
||||
if (!existing) {
|
||||
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
try {
|
||||
await sendReplyEmail({
|
||||
to: existing.email,
|
||||
reply,
|
||||
senderName: existing.name,
|
||||
originalMessage: existing.message,
|
||||
});
|
||||
await markMessageReply(messageId, reply);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Impossible d'envoyer la reponse.",
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { deleteMessage } from "@/lib/db";
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const messageId = Number(id);
|
||||
if (!Number.isInteger(messageId) || messageId <= 0) {
|
||||
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const deleted = await deleteMessage(messageId);
|
||||
if (!deleted) {
|
||||
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { setMessageStatus } from "@/lib/db";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
context: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await context.params;
|
||||
const messageId = Number(id);
|
||||
if (!Number.isInteger(messageId) || messageId <= 0) {
|
||||
return NextResponse.json({ message: "ID invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => null)) as { status?: string } | null;
|
||||
const status = body?.status;
|
||||
|
||||
if (status !== "pending" && status !== "replied") {
|
||||
return NextResponse.json({ message: "Statut invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const updated = await setMessageStatus(messageId, status);
|
||||
if (!updated) {
|
||||
return NextResponse.json({ message: "Message introuvable." }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { isAdminAuthenticated } from "@/lib/admin-auth";
|
||||
import { listMessages } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
const authenticated = await isAdminAuthenticated();
|
||||
if (!authenticated) {
|
||||
return NextResponse.json({ message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const messages = await listMessages();
|
||||
|
||||
return NextResponse.json({
|
||||
messages: messages.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
email: row.email,
|
||||
project: row.project,
|
||||
requestType: row.request_type,
|
||||
message: row.message,
|
||||
createdAt: row.created_at,
|
||||
repliedAt: row.replied_at,
|
||||
adminReply: row.admin_reply,
|
||||
status: row.status,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import {
|
||||
contactPayloadSchema,
|
||||
PROJECT_LABELS,
|
||||
REQUEST_TYPE_LABELS,
|
||||
type ContactPayload,
|
||||
} from "@/lib/contact";
|
||||
import { createMessage } from "@/lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const RATE_WINDOW_MS = 60_000;
|
||||
const RATE_MAX_REQUESTS = 5;
|
||||
const requestTracker = new Map<string, number[]>();
|
||||
|
||||
function getClientIdentifier(request: NextRequest): string {
|
||||
const xff = request.headers.get("x-forwarded-for");
|
||||
if (xff) {
|
||||
return xff.split(",")[0]?.trim() || "unknown";
|
||||
}
|
||||
|
||||
return request.headers.get("x-real-ip") || "unknown";
|
||||
}
|
||||
|
||||
function isRateLimited(identifier: string): boolean {
|
||||
const now = Date.now();
|
||||
const recentRequests = (requestTracker.get(identifier) || []).filter(
|
||||
(timestamp) => now - timestamp <= RATE_WINDOW_MS,
|
||||
);
|
||||
|
||||
if (recentRequests.length >= RATE_MAX_REQUESTS) {
|
||||
requestTracker.set(identifier, recentRequests);
|
||||
return true;
|
||||
}
|
||||
|
||||
recentRequests.push(now);
|
||||
requestTracker.set(identifier, recentRequests);
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatMessage(payload: ContactPayload): string {
|
||||
return [
|
||||
`Nom: ${payload.name}`,
|
||||
`Email: ${payload.email}`,
|
||||
`Projet: ${PROJECT_LABELS[payload.project]}`,
|
||||
`Type: ${REQUEST_TYPE_LABELS[payload.requestType]}`,
|
||||
`Source: ${payload.sourceUrl || "non renseignee"}`,
|
||||
"Message:",
|
||||
payload.message,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function sendEmail(payload: ContactPayload, message: string): Promise<boolean> {
|
||||
const smtpHost = process.env.SMTP_HOST;
|
||||
const smtpPortRaw = process.env.SMTP_PORT;
|
||||
const smtpUser = process.env.SMTP_USER;
|
||||
const smtpPass = process.env.SMTP_PASS;
|
||||
const contactTo = process.env.CONTACT_TO_EMAIL;
|
||||
|
||||
if (!smtpHost || !smtpUser || !smtpPass || !contactTo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const smtpPort = Number(smtpPortRaw || "587");
|
||||
const secure = smtpPort === 465;
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
host: smtpHost,
|
||||
port: Number.isNaN(smtpPort) ? 587 : smtpPort,
|
||||
secure,
|
||||
auth: {
|
||||
user: smtpUser,
|
||||
pass: smtpPass,
|
||||
},
|
||||
});
|
||||
|
||||
const fromEmail = process.env.CONTACT_FROM_EMAIL || smtpUser;
|
||||
|
||||
await transporter.sendMail({
|
||||
from: fromEmail,
|
||||
to: contactTo,
|
||||
replyTo: payload.email,
|
||||
subject: `[Contact] ${PROJECT_LABELS[payload.project]} - ${REQUEST_TYPE_LABELS[payload.requestType]}`,
|
||||
text: message,
|
||||
html: `<pre style="font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap;">${message}</pre>`,
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function sendDiscordWebhook(payload: ContactPayload, message: string): Promise<boolean> {
|
||||
const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
|
||||
if (!webhookUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: "📬 Nouveau message depuis le formulaire de contact",
|
||||
embeds: [
|
||||
{
|
||||
title: `${PROJECT_LABELS[payload.project]} - ${REQUEST_TYPE_LABELS[payload.requestType]}`,
|
||||
description: `\`\`\`\n${message}\n\`\`\``,
|
||||
color: 65366,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Discord webhook a retourne une erreur.");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const identifier = getClientIdentifier(request);
|
||||
|
||||
if (isRateLimited(identifier)) {
|
||||
return NextResponse.json(
|
||||
{ message: "Trop de tentatives. Merci de reessayer dans une minute." },
|
||||
{ status: 429 },
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json({ message: "Payload invalide." }, { status: 400 });
|
||||
}
|
||||
|
||||
const parsed = contactPayloadSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Donnees invalides.",
|
||||
issues: parsed.error.flatten().fieldErrors,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const payload = parsed.data;
|
||||
|
||||
if (payload.honeypot.trim().length > 0) {
|
||||
return NextResponse.json({ message: "Message envoye." }, { status: 200 });
|
||||
}
|
||||
|
||||
const formattedMessage = formatMessage(payload);
|
||||
|
||||
try {
|
||||
await createMessage({
|
||||
name: payload.name,
|
||||
email: payload.email,
|
||||
project: payload.project,
|
||||
requestType: payload.requestType,
|
||||
message: payload.message,
|
||||
sourceUrl: payload.sourceUrl || null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Contact API db error:", error);
|
||||
return NextResponse.json(
|
||||
{ message: "Impossible d'enregistrer le message pour le moment." },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const notificationResults = await Promise.allSettled([
|
||||
sendEmail(payload, formattedMessage),
|
||||
sendDiscordWebhook(payload, formattedMessage),
|
||||
]);
|
||||
|
||||
notificationResults.forEach((result, index) => {
|
||||
if (result.status === "rejected") {
|
||||
const channel = index === 0 ? "email" : "discord";
|
||||
console.warn(`Contact API ${channel} notification failed:`, result.reason);
|
||||
}
|
||||
});
|
||||
|
||||
return NextResponse.json({ message: "Message envoye avec succes." }, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export default function CguPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white">Conditions generales d'utilisation</h1>
|
||||
<section className="space-y-2">
|
||||
<p>L'utilisation du site implique l'acceptation des presentes conditions.</p>
|
||||
<p>Vous vous engagez a ne pas utiliser le formulaire pour spam, contenu illicite ou abus.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Disponibilite</h2>
|
||||
<p>Le service est fourni en l'etat et peut evoluer sans preavis.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Responsabilite</h2>
|
||||
<p>ArthurP ne peut etre tenu responsable des dommages indirects lies a l'usage des services.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
"use client";
|
||||
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { Link as LinkIcon, Mail, MessageCircle, Send, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
contactPayloadSchema,
|
||||
normalizeProjectParam,
|
||||
PROJECT_LABELS,
|
||||
projectValues,
|
||||
REQUEST_TYPE_LABELS,
|
||||
requestTypeValues,
|
||||
type ContactPayload,
|
||||
} from "@/lib/contact";
|
||||
import { footerSites, PROJECT_DESCRIPTIONS } from "@/lib/sites";
|
||||
|
||||
type FormState = ContactPayload;
|
||||
type StatusState = { type: "success" | "error"; message: string } | null;
|
||||
|
||||
type TouchedState = {
|
||||
[K in keyof FormState]?: boolean;
|
||||
};
|
||||
|
||||
const initialState: FormState = {
|
||||
name: "",
|
||||
email: "",
|
||||
project: "other",
|
||||
requestType: "question",
|
||||
message: "",
|
||||
honeypot: "",
|
||||
sourceUrl: "",
|
||||
};
|
||||
|
||||
const faqItems = [
|
||||
{
|
||||
question: "Tu reponds en combien de temps ?",
|
||||
answer: "En general sous 24h ouvrables. Pour les urgences techniques, je priorise les bugs critiques.",
|
||||
},
|
||||
{
|
||||
question: "Les projets sont-ils gratuits ?",
|
||||
answer: "La plupart ont un acces gratuit. Selon les besoins, certaines fonctionnalites peuvent etre premium ou auto-hebergees.",
|
||||
},
|
||||
{
|
||||
question: "Puis-je proposer une idee ?",
|
||||
answer: "Oui. Les suggestions sont les bienvenues, surtout si elles incluent le contexte, le besoin et un exemple concret.",
|
||||
},
|
||||
];
|
||||
|
||||
function inputClass(hasError: boolean) {
|
||||
return [
|
||||
"w-full rounded-2xl border bg-white/90 px-4 py-3 text-sm text-slate-900 shadow-sm outline-none transition focus:ring-2 dark:bg-slate-950/60 dark:text-slate-100",
|
||||
hasError
|
||||
? "border-rose-300 focus:border-rose-400 focus:ring-rose-200/70 dark:border-rose-500/40 dark:focus:ring-rose-500/30"
|
||||
: "border-slate-200 focus:border-cyan-300 focus:ring-cyan-200/70 dark:border-slate-800 dark:focus:border-cyan-500 dark:focus:ring-cyan-500/30",
|
||||
].join(" ");
|
||||
}
|
||||
|
||||
export default function ContactPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const [form, setForm] = useState<FormState>(initialState);
|
||||
const [touched, setTouched] = useState<TouchedState>({});
|
||||
const [status, setStatus] = useState<StatusState>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const projectFromQuery = normalizeProjectParam(searchParams.get("project"));
|
||||
if (!projectFromQuery) {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((current) => ({ ...current, project: projectFromQuery }));
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
setForm((current) => ({
|
||||
...current,
|
||||
sourceUrl: `${window.location.origin}${window.location.pathname}${window.location.search}`,
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const validation = useMemo(() => contactPayloadSchema.safeParse(form), [form]);
|
||||
|
||||
const fieldErrors = useMemo(() => {
|
||||
if (validation.success) {
|
||||
return {} as Record<keyof FormState, string | undefined>;
|
||||
}
|
||||
|
||||
const flattened = validation.error.flatten().fieldErrors;
|
||||
return {
|
||||
name: flattened.name?.[0],
|
||||
email: flattened.email?.[0],
|
||||
project: flattened.project?.[0],
|
||||
requestType: flattened.requestType?.[0],
|
||||
message: flattened.message?.[0],
|
||||
honeypot: flattened.honeypot?.[0],
|
||||
sourceUrl: flattened.sourceUrl?.[0],
|
||||
} satisfies Record<keyof FormState, string | undefined>;
|
||||
}, [validation]);
|
||||
|
||||
const isFormValid = validation.success;
|
||||
const selectedProjectLabel = PROJECT_LABELS[form.project];
|
||||
|
||||
const updateField = (name: keyof FormState, value: string) => {
|
||||
setForm((current) => ({ ...current, [name]: value }));
|
||||
setStatus(null);
|
||||
};
|
||||
|
||||
const markTouched = (name: keyof FormState) => {
|
||||
setTouched((current) => ({ ...current, [name]: true }));
|
||||
};
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setHasSubmitted(true);
|
||||
|
||||
if (!isFormValid) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message: "Le formulaire contient des erreurs. Corrige-les puis renvoie ton message.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setStatus(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/contact", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as
|
||||
| { message?: string }
|
||||
| null;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(payload?.message ?? "Une erreur est survenue lors de l'envoi.");
|
||||
}
|
||||
|
||||
setStatus({
|
||||
type: "success",
|
||||
message: "Message envoye ! Je te repondrai rapidement par email.",
|
||||
});
|
||||
setForm((current) => ({ ...initialState, project: current.project }));
|
||||
setTouched({});
|
||||
setHasSubmitted(false);
|
||||
} catch (error) {
|
||||
setStatus({
|
||||
type: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Impossible d'envoyer le message pour le moment.",
|
||||
});
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mx-auto w-full max-w-[700px] space-y-8 px-4 py-10 sm:px-6 sm:py-14">
|
||||
<section className="animate-fade-in space-y-4 rounded-3xl border border-white/40 bg-white/70 p-6 shadow-lg shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
|
||||
<div className="inline-flex items-center gap-2 rounded-full border border-emerald-200 bg-emerald-50 px-3 py-1 text-xs font-semibold text-emerald-700 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300">
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Reponse rapide ⚡
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-slate-900 dark:text-white sm:text-4xl">
|
||||
📬 Me contacter
|
||||
</h1>
|
||||
<p className="text-sm text-slate-600 dark:text-slate-300 sm:text-base">
|
||||
Une question, un bug ou une idee ? Je reponds rapidement.
|
||||
</p>
|
||||
<p className="text-sm font-medium text-cyan-700 dark:text-cyan-300">
|
||||
Temps de reponse moyen : < 24h
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="animate-fade-in rounded-3xl border border-white/40 bg-white/80 p-6 shadow-xl shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/80 sm:p-8">
|
||||
<div className="mb-6 rounded-2xl border border-cyan-100 bg-cyan-50/70 p-4 text-sm text-cyan-900 dark:border-cyan-900/40 dark:bg-cyan-900/20 dark:text-cyan-200">
|
||||
Vous contactez a propos de : <strong>{selectedProjectLabel}</strong>
|
||||
<div className="mt-1 text-xs opacity-80">{PROJECT_DESCRIPTIONS[form.project]}</div>
|
||||
</div>
|
||||
|
||||
<form className="space-y-5" onSubmit={handleSubmit} noValidate>
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="space-y-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Nom *
|
||||
<input
|
||||
name="name"
|
||||
value={form.name}
|
||||
onChange={(event) => updateField("name", event.target.value)}
|
||||
onBlur={() => markTouched("name")}
|
||||
className={inputClass(Boolean(fieldErrors.name && (touched.name || hasSubmitted)))}
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Arthur"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.name && (touched.name || hasSubmitted) ? (
|
||||
<span className="text-xs text-rose-600 dark:text-rose-400">{fieldErrors.name}</span>
|
||||
) : null}
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Email *
|
||||
<input
|
||||
name="email"
|
||||
value={form.email}
|
||||
onChange={(event) => updateField("email", event.target.value)}
|
||||
onBlur={() => markTouched("email")}
|
||||
className={inputClass(Boolean(fieldErrors.email && (touched.email || hasSubmitted)))}
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="vous@domaine.fr"
|
||||
required
|
||||
/>
|
||||
{fieldErrors.email && (touched.email || hasSubmitted) ? (
|
||||
<span className="text-xs text-rose-600 dark:text-rose-400">{fieldErrors.email}</span>
|
||||
) : null}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-2">
|
||||
<label className="space-y-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Projet concerne
|
||||
<select
|
||||
name="project"
|
||||
value={form.project}
|
||||
onChange={(event) => updateField("project", event.target.value)}
|
||||
onBlur={() => markTouched("project")}
|
||||
className={inputClass(Boolean(fieldErrors.project && (touched.project || hasSubmitted)))}
|
||||
>
|
||||
{projectValues.map((project) => (
|
||||
<option key={project} value={project}>
|
||||
{PROJECT_LABELS[project]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="space-y-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Type de demande
|
||||
<select
|
||||
name="requestType"
|
||||
value={form.requestType}
|
||||
onChange={(event) => updateField("requestType", event.target.value)}
|
||||
onBlur={() => markTouched("requestType")}
|
||||
className={inputClass(Boolean(fieldErrors.requestType && (touched.requestType || hasSubmitted)))}
|
||||
>
|
||||
{requestTypeValues.map((requestType) => (
|
||||
<option key={requestType} value={requestType}>
|
||||
{REQUEST_TYPE_LABELS[requestType]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-2 text-sm font-medium text-slate-700 dark:text-slate-200">
|
||||
Message *
|
||||
<textarea
|
||||
name="message"
|
||||
value={form.message}
|
||||
onChange={(event) => updateField("message", event.target.value)}
|
||||
onBlur={() => markTouched("message")}
|
||||
className={inputClass(Boolean(fieldErrors.message && (touched.message || hasSubmitted)))}
|
||||
placeholder="Explique ton besoin, le contexte, et le resultat attendu..."
|
||||
minLength={10}
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
{fieldErrors.message && (touched.message || hasSubmitted) ? (
|
||||
<span className="text-xs text-rose-600 dark:text-rose-400">{fieldErrors.message}</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500 dark:text-slate-400">Minimum 10 caracteres.</span>
|
||||
)}
|
||||
</label>
|
||||
|
||||
<label className="sr-only" aria-hidden="true">
|
||||
Laisser ce champ vide
|
||||
<input
|
||||
tabIndex={-1}
|
||||
autoComplete="off"
|
||||
name="honeypot"
|
||||
value={form.honeypot}
|
||||
onChange={(event) => updateField("honeypot", event.target.value)}
|
||||
className="absolute left-[-9999px] top-[-9999px]"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<input type="hidden" name="sourceUrl" value={form.sourceUrl} readOnly />
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isFormValid || isSubmitting}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-2xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white transition hover:bg-slate-700 disabled:cursor-not-allowed disabled:opacity-50 dark:bg-cyan-500 dark:text-slate-950 dark:hover:bg-cyan-400"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isSubmitting ? "Envoi en cours..." : "Envoyer le message"}
|
||||
</button>
|
||||
|
||||
{status ? (
|
||||
<p
|
||||
className={[
|
||||
"rounded-2xl border px-4 py-3 text-sm",
|
||||
status.type === "success"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-800 dark:border-emerald-500/30 dark:bg-emerald-500/10 dark:text-emerald-300"
|
||||
: "border-rose-200 bg-rose-50 text-rose-800 dark:border-rose-500/30 dark:bg-rose-500/10 dark:text-rose-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{status.type === "success" ? "✅ " : "⚠️ "}
|
||||
{status.message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="animate-fade-in rounded-3xl border border-white/40 bg-white/80 p-6 shadow-lg shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">Autres moyens de contact</h2>
|
||||
<div className="mt-4 grid gap-3 text-sm sm:grid-cols-3">
|
||||
<a
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-cyan-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-950/50"
|
||||
href="https://github.com/arthur-pbty"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<LinkIcon className="mb-2 h-5 w-5 text-slate-700 group-hover:text-cyan-600 dark:text-slate-200 dark:group-hover:text-cyan-300" />
|
||||
<p className="font-medium text-slate-900 dark:text-white">GitHub</p>
|
||||
<p className="text-slate-600 dark:text-slate-400">Pour signaler des bugs</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-cyan-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-950/50"
|
||||
href="mailto:contact@arthurp.fr"
|
||||
>
|
||||
<Mail className="mb-2 h-5 w-5 text-slate-700 group-hover:text-cyan-600 dark:text-slate-200 dark:group-hover:text-cyan-300" />
|
||||
<p className="font-medium text-slate-900 dark:text-white">Email</p>
|
||||
<p className="text-slate-600 dark:text-slate-400">Contact direct</p>
|
||||
</a>
|
||||
|
||||
<a
|
||||
className="group rounded-2xl border border-slate-200 bg-white p-4 transition hover:border-cyan-300 hover:shadow-md dark:border-slate-800 dark:bg-slate-950/50"
|
||||
href="https://discord.gg/NbMWHh54bp"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<MessageCircle className="mb-2 h-5 w-5 text-slate-700 group-hover:text-cyan-600 dark:text-slate-200 dark:group-hover:text-cyan-300" />
|
||||
<p className="font-medium text-slate-900 dark:text-white">Discord</p>
|
||||
<p className="text-slate-600 dark:text-slate-400">Discussion rapide</p>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="animate-fade-in rounded-3xl border border-white/40 bg-white/80 p-6 shadow-lg shadow-slate-900/5 backdrop-blur dark:border-slate-800 dark:bg-slate-900/70 sm:p-8">
|
||||
<h2 className="text-xl font-semibold text-slate-900 dark:text-white">FAQ rapide</h2>
|
||||
<div className="mt-4 space-y-3">
|
||||
{faqItems.map((item) => (
|
||||
<details
|
||||
key={item.question}
|
||||
className="group rounded-2xl border border-slate-200 bg-white px-4 py-3 open:border-cyan-300 dark:border-slate-800 dark:bg-slate-950/40"
|
||||
>
|
||||
<summary className="cursor-pointer list-none text-sm font-medium text-slate-900 dark:text-slate-100">
|
||||
{item.question}
|
||||
</summary>
|
||||
<p className="mt-2 text-sm leading-relaxed text-slate-600 dark:text-slate-400">{item.answer}</p>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<footer className="animate-fade-in w-full border-y border-slate-200 bg-white/90 text-sm text-slate-700 shadow-lg shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900/70 dark:text-slate-300">
|
||||
<div className="mx-auto w-full max-w-6xl p-6 sm:p-8">
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-slate-900 dark:text-white">ArthurP</p>
|
||||
<p>Developpeur web & homelab</p>
|
||||
<p className="mt-3 text-xs text-slate-500 dark:text-slate-400">
|
||||
Fait avec ❤️ et auto-heberge sur Proxmox
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-10 sm:grid-cols-[minmax(180px,0.75fr)_minmax(0,1.25fr)]">
|
||||
<div>
|
||||
<p className="mb-2 font-semibold text-slate-900 dark:text-white">Navigation</p>
|
||||
<ul className="space-y-1">
|
||||
<li><a className="hover:text-cyan-600 dark:hover:text-cyan-300" href="https://arthurp.fr" target="_blank" rel="noopener noreferrer">Accueil</a></li>
|
||||
<li><a className="hover:text-cyan-600 dark:hover:text-cyan-300" href="https://portfolio.arthurp.fr" target="_blank" rel="noopener noreferrer">Projets</a></li>
|
||||
<li><a className="hover:text-cyan-600 dark:hover:text-cyan-300" href="mailto:contact@arthurp.fr">Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 font-semibold text-slate-900 dark:text-white">Sites</p>
|
||||
<ul className="grid grid-cols-1 gap-x-8 gap-y-2 sm:grid-cols-2">
|
||||
{footerSites.map((site) => (
|
||||
<li key={site.domain}>
|
||||
<a
|
||||
className="hover:text-cyan-600 dark:hover:text-cyan-300"
|
||||
href={`https://${site.domain}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{site.label}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex flex-wrap gap-2 text-xs text-slate-500 dark:text-slate-400">
|
||||
<span className="rounded-full border border-slate-200 px-2 py-1 dark:border-slate-700">Docker</span>
|
||||
<span className="rounded-full border border-slate-200 px-2 py-1 dark:border-slate-700">Proxmox</span>
|
||||
<span className="rounded-full border border-slate-200 px-2 py-1 dark:border-slate-700">Next.js</span>
|
||||
<span className="rounded-full border border-slate-200 px-2 py-1 dark:border-slate-700">Node.js</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap items-center gap-4 text-xs">
|
||||
<a className="hover:text-cyan-600 dark:hover:text-cyan-300" href="https://github.com/arthur-pbty" target="_blank" rel="noopener noreferrer">
|
||||
GitHub
|
||||
</a>
|
||||
<a className="hover:text-cyan-600 dark:hover:text-cyan-300" href="mailto:contact@arthurp.fr">
|
||||
Email
|
||||
</a>
|
||||
<Link className="hover:text-cyan-600 dark:hover:text-cyan-300" href="/mentions-legales">
|
||||
Mentions legales
|
||||
</Link>
|
||||
<Link className="hover:text-cyan-600 dark:hover:text-cyan-300" href="/politique-confidentialite">
|
||||
Confidentialite
|
||||
</Link>
|
||||
<Link className="hover:text-cyan-600 dark:hover:text-cyan-300" href="/cgu">
|
||||
CGU
|
||||
</Link>
|
||||
<Link className="hover:text-cyan-600 dark:hover:text-cyan-300" href="/cookies">
|
||||
Cookies
|
||||
</Link>
|
||||
<Link className="hover:text-cyan-600 dark:hover:text-cyan-300" href="/admin">
|
||||
Admin
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
export default function CookiesPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white">Politique cookies</h1>
|
||||
<section className="space-y-2">
|
||||
<p>Ce site utilise un cookie technique pour la session admin (connexion au tableau de bord).</p>
|
||||
<p>Aucun cookie publicitaire ou de tracking tiers n'est depose par defaut.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Session admin</h2>
|
||||
<p>Le cookie admin est HttpOnly, SameSite=Lax et expire automatiquement.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 361 KiB |
@@ -1,8 +1,11 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--background: #f2f7fb;
|
||||
--foreground: #0f172a;
|
||||
--app-gradient: radial-gradient(circle at 20% 20%, #e0f2fe 0%, transparent 42%),
|
||||
radial-gradient(circle at 80% 0%, #fef3c7 0%, transparent 38%),
|
||||
linear-gradient(180deg, #f8fafc 0%, #eef2ff 100%);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
@@ -14,13 +17,36 @@
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
--background: #020617;
|
||||
--foreground: #e2e8f0;
|
||||
--app-gradient: radial-gradient(circle at 10% 10%, #0f766e66 0%, transparent 38%),
|
||||
radial-gradient(circle at 100% 0%, #1e293b 0%, transparent 38%),
|
||||
linear-gradient(180deg, #020617 0%, #0f172a 100%);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-geist-sans), sans-serif;
|
||||
}
|
||||
|
||||
.bg-app-gradient {
|
||||
background-image: var(--app-gradient);
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 420ms ease-out both;
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Contact ArthurP",
|
||||
description: "Page de contact centralisee pour les projets ArthurP.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -24,10 +24,12 @@ export default function RootLayout({
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
lang="fr"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="min-h-full bg-app-gradient text-slate-900 dark:text-slate-100 flex flex-col">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export default function MentionsLegalesPage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white">Mentions legales</h1>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Editeur</h2>
|
||||
<p>ArthurP - Developpeur web & homelab</p>
|
||||
<p>Email: contact@arthurp.fr</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Hebergement</h2>
|
||||
<p>Site auto-heberge sur infrastructure personnelle (Proxmox).</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Propriete intellectuelle</h2>
|
||||
<p>Les contenus, marques et elements visuels restent la propriete de leurs auteurs respectifs.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Contact</h2>
|
||||
<p>Pour toute question legale: contact@arthurp.fr</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +1,24 @@
|
||||
import Image from "next/image";
|
||||
import { Suspense } from "react";
|
||||
import ContactPageClient from "./contact-page-client";
|
||||
|
||||
export default function Home() {
|
||||
function LoadingContact() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 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
|
||||
/>
|
||||
<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.
|
||||
</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"
|
||||
>
|
||||
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"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
<div className="mx-auto w-full max-w-[700px] px-4 py-10 sm:px-6 sm:py-14">
|
||||
<div className="animate-pulse rounded-3xl border border-white/40 bg-white/70 p-8 shadow-lg dark:border-slate-800 dark:bg-slate-900/70">
|
||||
<div className="h-4 w-36 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="mt-4 h-8 w-56 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
<div className="mt-3 h-4 w-64 rounded bg-slate-200 dark:bg-slate-700" />
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<main className="w-full">
|
||||
<Suspense fallback={<LoadingContact />}>
|
||||
<ContactPageClient />
|
||||
</Suspense>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
export default function PolitiqueConfidentialitePage() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl space-y-6 px-4 py-10 text-sm leading-relaxed text-slate-700 dark:text-slate-300">
|
||||
<h1 className="text-3xl font-semibold text-slate-900 dark:text-white">Politique de confidentialite</h1>
|
||||
<section className="space-y-2">
|
||||
<p>
|
||||
Les informations saisies dans le formulaire (nom, email, message, projet, URL source) sont utilisees
|
||||
uniquement pour traiter votre demande.
|
||||
</p>
|
||||
<p>Les donnees sont stockees dans une base PostgreSQL auto-hebergee et ne sont pas revendues.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Base legale</h2>
|
||||
<p>Interet legitime: repondre aux demandes de contact et assurer le support des projets.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Conservation</h2>
|
||||
<p>Les messages sont conserves le temps necessaire au support et au suivi des echanges.</p>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Droits</h2>
|
||||
<p>Vous pouvez demander acces, rectification ou suppression via contact@arthurp.fr.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<RequestTypeValue, string> = {
|
||||
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<typeof contactPayloadSchema>;
|
||||
|
||||
export { normalizeProjectParam, PROJECT_LABELS };
|
||||
@@ -0,0 +1,175 @@
|
||||
import { Pool } from "pg";
|
||||
|
||||
let pool: Pool | null = null;
|
||||
let schemaReady: Promise<void> | 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<ContactMessageRow>(
|
||||
`
|
||||
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<ContactMessageRow>(
|
||||
`
|
||||
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<ContactMessageRow>(
|
||||
`
|
||||
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<ContactMessageRow>(
|
||||
`
|
||||
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<ContactMessageRow>(
|
||||
`
|
||||
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]);
|
||||
}
|
||||
@@ -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<ProjectValue, string> = Object.fromEntries(
|
||||
projectSites.map((site) => [site.value, site.label]),
|
||||
) as Record<ProjectValue, string>;
|
||||
|
||||
export const PROJECT_DESCRIPTIONS: Record<ProjectValue, string> = Object.fromEntries(
|
||||
projectSites.map((site) => [site.value, site.description]),
|
||||
) as Record<ProjectValue, string>;
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 +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 |
@@ -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 +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 +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 |