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:
Puechberty Arthur
2026-04-01 19:21:21 +02:00
parent 50553d5e92
commit f63eeb2e84
36 changed files with 2267 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
import AdminPanelClient from "./panel-client";
export default function AdminPage() {
return <AdminPanelClient />;
}
+392
View File
@@ -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&apos;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&apos;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>
);
}
+29
View File
@@ -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;
}
+15
View File
@@ -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;
}
+11
View File
@@ -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 });
}
+111
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapedReply = input.reply
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
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 },
);
}
}
+26
View File
@@ -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 });
}
+27
View File
@@ -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,
})),
});
}
+189
View File
@@ -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 });
}
+19
View File
@@ -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&apos;utilisation</h1>
<section className="space-y-2">
<p>L&apos;utilisation du site implique l&apos;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&apos;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&apos;usage des services.</p>
</section>
</main>
);
}
+454
View File
@@ -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 : &lt; 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>
);
}
+15
View File
@@ -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&apos;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>
);
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 361 KiB

+31 -5
View File
@@ -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
View File
@@ -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>
);
}
+24
View File
@@ -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
View File
@@ -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>
);
}
+26
View File
@@ -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>
);
}