diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..897fbc0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Dependencies +**/node_modules/ + +# Next.js build output +**/.next/ +**/out/ + +# Lock files (optional — retirer si tu veux les versionner) +**/package-lock.json + +# Next.js type declarations (auto-générés) +**/next-env.d.ts + +# Previews screenshots (local only) +previews/ + +# Env files +**/.env +**/.env.local +**/.env*.local + +# OS +.DS_Store +Thumbs.db diff --git a/templates/admin-dashboard/app/(dashboard)/contacts/page.tsx b/templates/admin-dashboard/app/(dashboard)/contacts/page.tsx new file mode 100644 index 0000000..cce5fd1 --- /dev/null +++ b/templates/admin-dashboard/app/(dashboard)/contacts/page.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from "next"; +import ContactsTable from "@/components/ui/ContactsTable"; +import { MOCK_CONTACTS } from "@/lib/mock-data"; + +export const metadata: Metadata = { title: "Contacts" }; + +export default function ContactsPage() { + return ( +
+
+
+

Contacts

+

{MOCK_CONTACTS.length} demandes au total

+
+ +
+ +
+ ); +} diff --git a/templates/admin-dashboard/app/(dashboard)/layout.tsx b/templates/admin-dashboard/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..dbfd5e5 --- /dev/null +++ b/templates/admin-dashboard/app/(dashboard)/layout.tsx @@ -0,0 +1,14 @@ +import Sidebar from "@/components/layout/Sidebar"; +import Topbar from "@/components/layout/Topbar"; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + return ( +
+ +
+ +
{children}
+
+
+ ); +} diff --git a/templates/admin-dashboard/app/(dashboard)/page.tsx b/templates/admin-dashboard/app/(dashboard)/page.tsx new file mode 100644 index 0000000..9967354 --- /dev/null +++ b/templates/admin-dashboard/app/(dashboard)/page.tsx @@ -0,0 +1,62 @@ +import StatsCards from "@/components/ui/StatsCards"; +import WeeklyChart from "@/components/ui/WeeklyChart"; +import ContactsTable from "@/components/ui/ContactsTable"; +import { MOCK_CONTACTS, MOCK_STATS } from "@/lib/mock-data"; + +export default function DashboardPage() { + const urgent = MOCK_CONTACTS.filter((c) => c.urgency && c.status !== "done" && c.status !== "archived"); + + return ( +
+
+

Tableau de bord

+

Bienvenue, voici un résumé de l'activité.

+
+ + {/* Urgent alert */} + {urgent.length > 0 && ( +
+ + ⚡ + +
+

+ {urgent.length} demande{urgent.length > 1 ? "s" : ""} urgente{urgent.length > 1 ? "s" : ""} en attente +

+

Traiter en priorité

+
+
+ )} + + + +
+
+ +
+
+

Sources de contact

+
+ {[ + { label: "Formulaire contact", pct: 52, color: "bg-brand-600" }, + { label: "Google / SEO", pct: 28, color: "bg-green-500" }, + { label: "Bouche à oreille", pct: 12, color: "bg-purple-500" }, + { label: "Réseaux sociaux", pct: 8, color: "bg-orange-500" }, + ].map((s) => ( +
+
+ {s.label}{s.pct}% +
+
+
+
+
+ ))} +
+
+
+ + +
+ ); +} diff --git a/templates/admin-dashboard/app/globals.css b/templates/admin-dashboard/app/globals.css new file mode 100644 index 0000000..3eb6cfe --- /dev/null +++ b/templates/admin-dashboard/app/globals.css @@ -0,0 +1,24 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + html { -webkit-font-smoothing: antialiased; } + body { @apply bg-slate-50 text-slate-800; } + *:focus-visible { @apply outline-2 outline-offset-2 outline-brand-500; } +} + +@layer components { + .stat-card { + @apply rounded-2xl bg-white border border-slate-100 p-6 shadow-sm; + } + .btn-sm { + @apply inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors duration-150; + } + .sidebar-link { + @apply flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-400 transition-colors duration-150 hover:bg-slate-800 hover:text-white; + } + .sidebar-link-active { + @apply flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium bg-brand-600 text-white; + } +} diff --git a/templates/admin-dashboard/app/layout.tsx b/templates/admin-dashboard/app/layout.tsx new file mode 100644 index 0000000..c601413 --- /dev/null +++ b/templates/admin-dashboard/app/layout.tsx @@ -0,0 +1,20 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; + +const inter = Inter({ subsets: ["latin"], display: "swap", variable: "--font-inter" }); +export const viewport: Viewport = { themeColor: "#4f46e5" }; + +export const metadata: Metadata = { + title: { default: "Dashboard Admin", template: "%s | Admin" }, + description: "Interface d'administration — gestion des contacts et demandes clients", + robots: { index: false, follow: false }, +}; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} diff --git a/templates/admin-dashboard/components/layout/Sidebar.tsx b/templates/admin-dashboard/components/layout/Sidebar.tsx new file mode 100644 index 0000000..3444f80 --- /dev/null +++ b/templates/admin-dashboard/components/layout/Sidebar.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { usePathname } from "next/navigation"; +import Link from "next/link"; +import { + LayoutDashboard, Users, Settings, LogOut, Wrench, Bell, BarChart3, +} from "lucide-react"; +import { cn } from "@/lib/utils"; + +const NAV = [ + { label: "Dashboard", href: "/", icon: LayoutDashboard }, + { label: "Contacts", href: "/contacts", icon: Users }, + { label: "Statistiques", href: "/stats", icon: BarChart3 }, + { label: "Paramètres", href: "/settings", icon: Settings }, +]; + +export default function Sidebar() { + const path = usePathname(); + + return ( + + ); +} diff --git a/templates/admin-dashboard/components/layout/Topbar.tsx b/templates/admin-dashboard/components/layout/Topbar.tsx new file mode 100644 index 0000000..cb91cc8 --- /dev/null +++ b/templates/admin-dashboard/components/layout/Topbar.tsx @@ -0,0 +1,80 @@ +"use client"; + +import { useState } from "react"; +import { Bell, Search, Menu, X } from "lucide-react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +const NAV = [ + { label: "Dashboard", href: "/" }, + { label: "Contacts", href: "/contacts" }, +]; + +const BREADCRUMBS: Record = { + "/": "Dashboard", + "/contacts": "Contacts", + "/stats": "Statistiques", + "/settings": "Paramètres", +}; + +type Props = { notifCount?: number }; + +export default function Topbar({ notifCount = 3 }: Props) { + const [mobileOpen, setMobileOpen] = useState(false); + const path = usePathname(); + + return ( + <> +
+ {/* Mobile menu toggle */} + + + {/* Breadcrumb */} +
+ {BREADCRUMBS[path] ?? "—"} +
+ + {/* Search */} +
+
+ + +
+
+ + {/* Right */} +
+ +
JD
+
+
+ + {/* Mobile nav drawer */} + {mobileOpen && ( +
setMobileOpen(false)}> + +
+ )} + + ); +} diff --git a/templates/admin-dashboard/components/ui/ContactsTable.tsx b/templates/admin-dashboard/components/ui/ContactsTable.tsx new file mode 100644 index 0000000..487c755 --- /dev/null +++ b/templates/admin-dashboard/components/ui/ContactsTable.tsx @@ -0,0 +1,180 @@ +"use client"; + +import { useState } from "react"; +import { Phone, Mail, AlertTriangle, ChevronDown, Eye } from "lucide-react"; +import type { Contact, ContactStatus } from "@/lib/types"; +import { cn, formatDate, timeAgo, STATUS_CONFIG } from "@/lib/utils"; + +type Props = { contacts: Contact[]; onStatusChange?: (id: string, status: ContactStatus) => void }; + +const STATUSES: ContactStatus[] = ["new", "in_progress", "done", "archived"]; +const FILTERS: Array<{ label: string; value: ContactStatus | "all" }> = [ + { label: "Tous", value: "all" }, + { label: "Nouveaux", value: "new" }, + { label: "En cours", value: "in_progress" }, + { label: "Traités", value: "done" }, +]; + +function StatusBadge({ status }: { status: ContactStatus }) { + const cfg = STATUS_CONFIG[status]; + return ( + + + {cfg.label} + + ); +} + +function StatusSelect({ current, onChange }: { current: ContactStatus; onChange: (s: ContactStatus) => void }) { + return ( + + ); +} + +export default function ContactsTable({ contacts: initialContacts, onStatusChange }: Props) { + const [contacts, setContacts] = useState(initialContacts); + const [filter, setFilter] = useState("all"); + const [expanded, setExpanded] = useState(null); + + const filtered = filter === "all" ? contacts : contacts.filter((c) => c.status === filter); + + const handleStatus = (id: string, status: ContactStatus) => { + setContacts((prev) => prev.map((c) => (c.id === id ? { ...c, status } : c))); + onStatusChange?.(id, status); + }; + + return ( +
+ {/* Header */} +
+
+

Demandes de contact

+

{filtered.length} demande{filtered.length > 1 ? "s" : ""}

+
+
+ {FILTERS.map((f) => ( + + ))} +
+
+ + {/* Table */} +
+ + + + {["Contact", "Service", "Statut", "Reçu", "Actions"].map((h) => ( + + ))} + + + + {filtered.map((contact) => ( + <> + setExpanded(expanded === contact.id ? null : contact.id)} + > + + + + + + + + {/* Expanded row */} + {expanded === contact.id && ( + + + + )} + + ))} + +
+ {h} +
+
+
+ {contact.name[0]} +
+
+
+

{contact.name}

+ {contact.urgency && } +
+

{contact.email}

+
+
+
+ {contact.service} + + + +

{timeAgo(contact.createdAt)}

+

{formatDate(contact.createdAt)}

+
e.stopPropagation()}> +
+ handleStatus(contact.id, s)} /> + {contact.phone && ( + + + + )} + + + +
+
+
+
+

Message

+

{contact.message}

+
+ {contact.phone && ( +
+

Téléphone

+ {contact.phone} +
+ )} + {contact.source && ( +
+

Source

+

{contact.source}

+
+ )} + {contact.urgency && ( +
+ + Demande urgente — intervention rapide requise +
+ )} +
+
+ + {filtered.length === 0 && ( +
+

Aucune demande dans cette catégorie

+
+ )} +
+
+ ); +} diff --git a/templates/admin-dashboard/components/ui/StatsCards.tsx b/templates/admin-dashboard/components/ui/StatsCards.tsx new file mode 100644 index 0000000..3abc3a1 --- /dev/null +++ b/templates/admin-dashboard/components/ui/StatsCards.tsx @@ -0,0 +1,67 @@ +import { TrendingUp, Users, Clock, CheckCircle, AlertCircle } from "lucide-react"; +import type { DashboardStats } from "@/lib/types"; + +type Props = { stats: DashboardStats }; + +export default function StatsCards({ stats }: Props) { + const cards = [ + { + label: "Total contacts", + value: stats.total, + icon: Users, + iconBg: "bg-brand-50", + iconColor: "text-brand-600", + trend: "+12% ce mois", + trendUp: true, + }, + { + label: "Nouveaux aujourd'hui", + value: stats.newToday, + icon: AlertCircle, + iconBg: "bg-blue-50", + iconColor: "text-blue-600", + trend: "À traiter", + trendUp: null, + }, + { + label: "En cours", + value: stats.inProgress, + icon: Clock, + iconBg: "bg-amber-50", + iconColor: "text-amber-600", + trend: "Attente réponse", + trendUp: null, + }, + { + label: "Taux de réponse", + value: `${stats.responseRate}%`, + icon: CheckCircle, + iconBg: "bg-green-50", + iconColor: "text-green-600", + trend: `Délai moy. ${stats.avgResponseTime}`, + trendUp: true, + }, + ]; + + return ( +
+ {cards.map((card) => ( +
+
+

{card.label}

+

{card.value}

+
+ {card.trendUp !== null && ( + + )} + {card.trend} +
+
+
+ +
+
+ ))} +
+ ); +} diff --git a/templates/admin-dashboard/components/ui/WeeklyChart.tsx b/templates/admin-dashboard/components/ui/WeeklyChart.tsx new file mode 100644 index 0000000..5673029 --- /dev/null +++ b/templates/admin-dashboard/components/ui/WeeklyChart.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { WEEKLY_DATA } from "@/lib/mock-data"; + +export default function WeeklyChart() { + const max = Math.max(...WEEKLY_DATA.map((d) => d.contacts)); + + return ( +
+
+
+

Contacts cette semaine

+

35 contacts au total

+
+ +18% vs semaine passée +
+ +
+ {WEEKLY_DATA.map((d) => { + const pct = (d.contacts / max) * 100; + const isMax = d.contacts === max; + return ( +
+ {d.contacts} +
+
+
+ {d.day} +
+ ); + })} +
+
+ ); +} diff --git a/templates/admin-dashboard/lib/mock-data.ts b/templates/admin-dashboard/lib/mock-data.ts new file mode 100644 index 0000000..3d7117e --- /dev/null +++ b/templates/admin-dashboard/lib/mock-data.ts @@ -0,0 +1,28 @@ +import type { Contact, DashboardStats } from "./types"; + +export const MOCK_CONTACTS: Contact[] = [ + { id: "1", name: "Sophie Martin", email: "sophie.m@gmail.com", phone: "06 12 34 56 78", service: "Dépannage urgent", message: "Fuite sous l'évier cuisine, eau partout, urgent svp", urgency: true, status: "new", createdAt: "2025-04-26T08:23:00Z", source: "Formulaire contact" }, + { id: "2", name: "Patrick Dubois", email: "p.dubois@orange.fr", phone: "07 98 76 54 32", service: "Chauffage", message: "Chaudière en panne depuis 2 jours, besoin devis remplacement", urgency: false, status: "in_progress", createdAt: "2025-04-25T14:10:00Z", source: "Google Ads" }, + { id: "3", name: "Marie-Claire Tissot", email: "mc.tissot@hotmail.fr", service: "Salle de bain", message: "Souhait rénovation complète salle de bain, budget ~8000€", urgency: false, status: "done", createdAt: "2025-04-25T09:45:00Z", source: "Bouche à oreille" }, + { id: "4", name: "Thomas Lefèvre", email: "thomas.l@entreprise.fr", phone: "06 11 22 33 44", service: "Plomberie générale", message: "Plusieurs robinets à changer dans notre bureau. 3 WC à réviser.", urgency: false, status: "new", createdAt: "2025-04-26T07:05:00Z", source: "Formulaire contact" }, + { id: "5", name: "Isabelle Rondeau", email: "isa.rondeau@gmail.com", phone: "06 55 44 33 22", service: "Dépannage urgent", message: "Colonne d'eau bouchée immeuble 6 étages, locataires mécontents", urgency: true, status: "in_progress", createdAt: "2025-04-26T06:30:00Z", source: "Appel → formulaire" }, + { id: "6", name: "François Bernard", email: "f.bernard@laposte.net", service: "Rénovation énergétique", message: "Intéressé par PAC air/eau + plancher chauffant maison 120m²", urgency: false, status: "archived", createdAt: "2025-04-24T16:20:00Z", source: "SEO" }, +]; + +export const MOCK_STATS: DashboardStats = { + total: 6, + newToday: 2, + inProgress: 2, + responseRate: 94, + avgResponseTime: "1h42", +}; + +export const WEEKLY_DATA = [ + { day: "Lun", contacts: 4 }, + { day: "Mar", contacts: 7 }, + { day: "Mer", contacts: 5 }, + { day: "Jeu", contacts: 9 }, + { day: "Ven", contacts: 6 }, + { day: "Sam", contacts: 3 }, + { day: "Dim", contacts: 1 }, +]; diff --git a/templates/admin-dashboard/lib/types.ts b/templates/admin-dashboard/lib/types.ts new file mode 100644 index 0000000..fbff1a0 --- /dev/null +++ b/templates/admin-dashboard/lib/types.ts @@ -0,0 +1,23 @@ +export type ContactStatus = "new" | "in_progress" | "done" | "archived"; +export type ContactUrgency = "low" | "medium" | "high"; + +export type Contact = { + id: string; + name: string; + email: string; + phone?: string; + service: string; + message: string; + urgency: boolean; + status: ContactStatus; + createdAt: string; + source?: string; +}; + +export type DashboardStats = { + total: number; + newToday: number; + inProgress: number; + responseRate: number; + avgResponseTime: string; +}; diff --git a/templates/admin-dashboard/lib/utils.ts b/templates/admin-dashboard/lib/utils.ts new file mode 100644 index 0000000..90aa2ce --- /dev/null +++ b/templates/admin-dashboard/lib/utils.ts @@ -0,0 +1,25 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; +import type { ContactStatus } from "./types"; + +export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } + +export function formatDate(iso: string): string { + return new Intl.DateTimeFormat("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }).format(new Date(iso)); +} + +export function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `il y a ${mins} min`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `il y a ${hrs}h`; + return `il y a ${Math.floor(hrs / 24)}j`; +} + +export const STATUS_CONFIG: Record = { + new: { label: "Nouveau", bg: "bg-blue-50", text: "text-blue-700", dot: "bg-blue-500" }, + in_progress: { label: "En cours", bg: "bg-amber-50", text: "text-amber-700", dot: "bg-amber-500" }, + done: { label: "Traité", bg: "bg-green-50", text: "text-green-700", dot: "bg-green-500" }, + archived: { label: "Archivé", bg: "bg-slate-100", text: "text-slate-500", dot: "bg-slate-400" }, +}; diff --git a/templates/admin-dashboard/next.config.mjs b/templates/admin-dashboard/next.config.mjs new file mode 100644 index 0000000..c413506 --- /dev/null +++ b/templates/admin-dashboard/next.config.mjs @@ -0,0 +1,29 @@ + + +const nextConfig = { + images: { + formats: ["image/avif", "image/webp"], + remotePatterns: [ + { protocol: "https", hostname: "images.unsplash.com" }, + ], + }, + compress: true, + poweredByHeader: false, + headers: async () => [ + { + source: "/(.*)", + headers: [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "X-XSS-Protection", value: "1; mode=block" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { + key: "Permissions-Policy", + value: "camera=(), microphone=(), geolocation=()", + }, + ], + }, + ], +}; + +export default nextConfig; diff --git a/templates/admin-dashboard/package.json b/templates/admin-dashboard/package.json new file mode 100644 index 0000000..9fc58b4 --- /dev/null +++ b/templates/admin-dashboard/package.json @@ -0,0 +1,34 @@ +{ + "name": "artisan-pme-premium", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "next": "^14.2.0", + "react": "^18.3.0", + "react-dom": "^18.3.0", + "framer-motion": "^11.0.0", + "react-hook-form": "^7.51.0", + "zod": "^3.22.0", + "@hookform/resolvers": "^3.3.0", + "lucide-react": "^0.372.0", + "clsx": "^2.1.0", + "tailwind-merge": "^2.2.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/react": "^18.3.0", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.0", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.4.0" + } +} diff --git a/templates/admin-dashboard/postcss.config.js b/templates/admin-dashboard/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/templates/admin-dashboard/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/templates/admin-dashboard/tailwind.config.ts b/templates/admin-dashboard/tailwind.config.ts new file mode 100644 index 0000000..06eddd0 --- /dev/null +++ b/templates/admin-dashboard/tailwind.config.ts @@ -0,0 +1,16 @@ +import type { Config } from "tailwindcss"; +const config: Config = { + content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}", "./lib/**/*.{ts,tsx}"], + darkMode: "class", + theme: { + extend: { + colors: { + brand: { 500: "#6366f1", 600: "#4f46e5", 700: "#4338ca" }, + sidebar: { bg: "#0f172a", hover: "#1e293b", active: "#1e3a5f" }, + }, + fontFamily: { sans: ["var(--font-inter)", "system-ui", "sans-serif"] }, + }, + }, + plugins: [], +}; +export default config; diff --git a/templates/admin-dashboard/tsconfig.json b/templates/admin-dashboard/tsconfig.json new file mode 100644 index 0000000..0c66908 --- /dev/null +++ b/templates/admin-dashboard/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/templates/artisan-pme-premium/app/api/contact/route.ts b/templates/artisan-pme-premium/app/api/contact/route.ts new file mode 100644 index 0000000..2b79d1f --- /dev/null +++ b/templates/artisan-pme-premium/app/api/contact/route.ts @@ -0,0 +1,91 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; + +const schema = z.object({ + name: z.string().min(2).max(100), + email: z.string().email(), + phone: z.string().optional(), + service: z.string().min(1).max(100), + message: z.string().min(10).max(2000), + urgency: z.boolean().optional(), +}); + +const RATE_LIMIT_MAP = new Map(); +const WINDOW_MS = 60_000; +const MAX_REQUESTS = 3; + +function getClientIp(req: NextRequest): string { + return ( + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown" + ); +} + +export async function POST(req: NextRequest) { + // Rate limiting + const ip = getClientIp(req); + const now = Date.now(); + const entry = RATE_LIMIT_MAP.get(ip); + + if (entry && now - entry.ts < WINDOW_MS) { + if (entry.count >= MAX_REQUESTS) { + return NextResponse.json( + { error: "Trop de requêtes. Réessayez dans une minute." }, + { status: 429 } + ); + } + entry.count++; + } else { + RATE_LIMIT_MAP.set(ip, { count: 1, ts: now }); + } + + // Parse + validate body + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Requête invalide" }, { status: 400 }); + } + + const parsed = schema.safeParse(body); + if (!parsed.success) { + return NextResponse.json( + { error: "Données invalides", details: parsed.error.flatten() }, + { status: 422 } + ); + } + + const { name, email, phone, service, message, urgency } = parsed.data; + + /* + * INTÉGRATION EMAIL — connecter ici votre provider : + * + * Option A — Resend (recommandé) : + * import { Resend } from "resend"; + * const resend = new Resend(process.env.RESEND_API_KEY); + * await resend.emails.send({ from, to, subject, html }); + * + * Option B — Nodemailer + SMTP : + * const transport = nodemailer.createTransport({ ... }); + * await transport.sendMail({ ... }); + * + * Option C — Brevo / SendGrid via API REST + */ + + console.info("[Contact]", { + name, + email, + phone, + service, + urgency, + messageLength: message.length, + ip, + ts: new Date().toISOString(), + }); + + return NextResponse.json( + { success: true, message: "Message reçu. Nous vous répondons sous 2h." }, + { status: 200 } + ); +} diff --git a/templates/artisan-pme-premium/app/galerie/page.tsx b/templates/artisan-pme-premium/app/galerie/page.tsx new file mode 100644 index 0000000..67a3cb2 --- /dev/null +++ b/templates/artisan-pme-premium/app/galerie/page.tsx @@ -0,0 +1,69 @@ +import type { Metadata } from "next"; +import Header from "@/components/Header"; +import Footer from "@/components/Footer"; +import Gallery from "@/components/Gallery"; +import { SITE_CONFIG } from "@/lib/schema"; + +export const metadata: Metadata = { + title: "Galerie de réalisations", + description: + "Découvrez nos chantiers : installations de chaudières, salles de bain, rénovations énergétiques sur Lyon. Photos avant/après de nos réalisations.", + alternates: { canonical: `${SITE_CONFIG.url}/galerie` }, + openGraph: { + title: `Galerie — ${SITE_CONFIG.shortName}`, + description: "Nos réalisations en plomberie, chauffage et salle de bain sur Lyon.", + }, +}; + +export default function GaleriePage() { + return ( +
+
+ + {/* Page hero */} +
+
+

+ Nos réalisations +

+

+ Galerie chantiers +

+

+ Chaque photo est un engagement tenu. Retrouvez ici nos derniers + chantiers plomberie, chauffage et rénovation sur Lyon et le Grand Lyon. +

+
+
+ + {/* Gallery */} +
+
+ +
+
+ + {/* CTA */} +
+
+

+ Votre projet ressemble à l'un de ces chantiers ? +

+

+ Obtenez un devis gratuit et personnalisé sous 2 heures. +

+ +
+
+ +
+
+ ); +} diff --git a/templates/artisan-pme-premium/app/globals.css b/templates/artisan-pme-premium/app/globals.css new file mode 100644 index 0000000..baa1cc5 --- /dev/null +++ b/templates/artisan-pme-premium/app/globals.css @@ -0,0 +1,73 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + --font-inter: "Inter", system-ui, sans-serif; + } + + html { + scroll-behavior: smooth; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + body { + @apply bg-white text-slate-800; + } + + ::selection { + @apply bg-brand-500 text-white; + } + + *:focus-visible { + @apply outline-2 outline-offset-2 outline-brand-500; + } +} + +@layer components { + .btn-primary { + @apply inline-flex items-center gap-2 rounded-full bg-brand-600 px-6 py-3 text-sm font-semibold text-white shadow-lg shadow-brand-600/25 transition-all duration-200 hover:bg-brand-700 hover:-translate-y-0.5 hover:shadow-xl hover:shadow-brand-600/30 active:translate-y-0 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand-600; + } + + .btn-secondary { + @apply inline-flex items-center gap-2 rounded-full border-2 border-white/30 px-6 py-3 text-sm font-semibold text-white backdrop-blur-sm transition-all duration-200 hover:border-white/60 hover:bg-white/10 hover:-translate-y-0.5 active:translate-y-0; + } + + .btn-outline { + @apply inline-flex items-center gap-2 rounded-full border-2 border-brand-600 px-6 py-3 text-sm font-semibold text-brand-600 transition-all duration-200 hover:bg-brand-600 hover:text-white hover:-translate-y-0.5 active:translate-y-0; + } + + .section-title { + @apply text-3xl font-bold tracking-tight text-slate-900 sm:text-4xl; + } + + .section-subtitle { + @apply mt-4 text-lg text-slate-600 max-w-2xl; + } + + .card { + @apply rounded-2xl border border-slate-100 bg-white p-6 shadow-sm transition-all duration-300 hover:shadow-md hover:-translate-y-1; + } + + .glass { + @apply bg-white/80 backdrop-blur-md border border-white/20; + } + + .input-field { + @apply w-full rounded-xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-800 placeholder:text-slate-400 transition-colors duration-200 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20; + } +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } + + .animate-delay-100 { animation-delay: 100ms; } + .animate-delay-200 { animation-delay: 200ms; } + .animate-delay-300 { animation-delay: 300ms; } + .animate-delay-400 { animation-delay: 400ms; } + .animate-delay-500 { animation-delay: 500ms; } +} diff --git a/templates/artisan-pme-premium/app/layout.tsx b/templates/artisan-pme-premium/app/layout.tsx new file mode 100644 index 0000000..0e04b32 --- /dev/null +++ b/templates/artisan-pme-premium/app/layout.tsx @@ -0,0 +1,83 @@ +import type { Metadata, Viewport } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { buildLocalBusinessSchema, SITE_CONFIG } from "@/lib/schema"; + +const inter = Inter({ + subsets: ["latin"], + display: "swap", + variable: "--font-inter", +}); + +export const viewport: Viewport = { + themeColor: "#0369a1", + width: "device-width", + initialScale: 1, +}; + +export const metadata: Metadata = { + metadataBase: new URL(SITE_CONFIG.url), + title: { + default: `${SITE_CONFIG.name} | Devis gratuit sous 2h`, + template: `%s | ${SITE_CONFIG.shortName}`, + }, + description: SITE_CONFIG.description, + keywords: [ + "plombier Lyon", + "plombier chauffagiste Lyon", + "dépannage plomberie Lyon", + "chauffagiste Lyon", + "installation chaudière Lyon", + "urgence plomberie 69", + ], + authors: [{ name: SITE_CONFIG.name }], + creator: SITE_CONFIG.name, + openGraph: { + type: "website", + locale: "fr_FR", + url: SITE_CONFIG.url, + siteName: SITE_CONFIG.name, + title: `${SITE_CONFIG.name} | Artisan de confiance à Lyon`, + description: SITE_CONFIG.description, + images: [ + { + url: "/og-image.jpg", + width: 1200, + height: 630, + alt: `${SITE_CONFIG.name} — Plombier chauffagiste Lyon`, + }, + ], + }, + twitter: { + card: "summary_large_image", + title: SITE_CONFIG.name, + description: SITE_CONFIG.description, + images: ["/og-image.jpg"], + }, + robots: { + index: true, + follow: true, + googleBot: { index: true, follow: true, "max-image-preview": "large" }, + }, + alternates: { canonical: SITE_CONFIG.url }, +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const schema = buildLocalBusinessSchema(SITE_CONFIG); + + return ( + + +