mirror of
https://github.com/arthur-pbty/contact.git
synced 2026-06-11 15:55:57 +02:00
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.
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 361 KiB |
+31
-5
@@ -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;
|
||||
}
|
||||
|
||||
+6
-4
@@ -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>
|
||||
);
|
||||
}
|
||||
+19
-60
@@ -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>
|
||||
<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 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user