Function bodies 74 total
escapeHtml function · typescript · L15-L22 (8 LOC)apps/website/src/app/api/contact/route.ts
function escapeHtml(str: string): string {
return str
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}validate function · typescript · L25-L45 (21 LOC)apps/website/src/app/api/contact/route.ts
function validate(body: unknown): ContactPayload | string {
if (!body || typeof body !== "object") return "Corps de la requête invalide.";
const { name, email, phone, message } = body as Record<string, unknown>;
if (typeof name !== "string" || name.trim().length < 2)
return "Le nom doit contenir au moins 2 caractères.";
if (typeof email !== "string" || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email))
return "Adresse email invalide.";
if (typeof message !== "string" || message.trim().length < 10)
return "Le message doit contenir au moins 10 caractères.";
if (message.trim().length > 2000)
return "Le message ne doit pas dépasser 2000 caractères.";
return {
name: name.trim(),
email: email.trim().toLowerCase(),
phone: typeof phone === "string" ? phone.trim() : undefined,
message: message.trim(),
};
}isValidOrigin function · typescript · L48-L73 (26 LOC)apps/website/src/app/api/contact/route.ts
function isValidOrigin(request: Request): boolean {
// In development, skip origin check entirely
if (process.env.NODE_ENV === "development") return true;
const allowedHost = new URL(siteConfig.url).host;
const origin = request.headers.get("origin");
const referer = request.headers.get("referer");
if (origin) {
try {
return new URL(origin).host === allowedHost;
} catch {
return false;
}
}
if (referer) {
try {
return new URL(referer).host === allowedHost;
} catch {
return false;
}
}
return false;
}fetchWithRetry function · typescript · L76-L104 (29 LOC)apps/website/src/app/api/contact/route.ts
async function fetchWithRetry(
url: string,
options: RequestInit,
maxRetries = 3
): Promise<Response> {
const backoff = [500, 1000, 2000];
let lastError: Error | undefined;
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await fetch(url, options);
// Don't retry on client errors (4xx) — only on server errors (5xx) or network issues
if (res.ok || (res.status >= 400 && res.status < 500)) {
return res;
}
// Server error — retry
lastError = new Error(`Resend API returned ${res.status}`);
} catch (err) {
// Network error — retry
lastError = err instanceof Error ? err : new Error(String(err));
}
if (attempt < maxRetries - 1) {
await new Promise((resolve) => setTimeout(resolve, backoff[attempt]));
}
}
throw lastError;
}POST function · typescript · L107-L257 (151 LOC)apps/website/src/app/api/contact/route.ts
export async function POST(request: Request) {
try {
// CSRF: verify Origin/Referer
if (!isValidOrigin(request)) {
return NextResponse.json(
{ error: "Requête non autorisée." },
{ status: 403 }
);
}
// Rate limiting (skip if Upstash not configured — dev mode)
const rateLimit = getContactRateLimit();
if (rateLimit) {
const headersList = await headers();
const ip =
headersList.get("x-forwarded-for")?.split(",")[0]?.trim() ??
"anonymous";
const { success, remaining } = await rateLimit.limit(ip);
if (!success) {
return NextResponse.json(
{
error:
"Trop de messages envoyés. Réessayez dans quelques minutes.",
},
{ status: 429, headers: { "X-RateLimit-Remaining": String(remaining) } }
);
}
}
const body = await request.json();
const result = validate(body);
if (typeof result === "string") {
return NAProposPage function · typescript · L25-L169 (145 LOC)apps/website/src/app/a-propos/page.tsx
export default function AProposPage() {
return (
<div>
{/* ═══════════════════════ HEADER ═══════════════════════ */}
<section className="relative pb-16 pt-20 md:pb-24 md:pt-28">
<div className="mx-auto max-w-6xl px-6">
<div className="grid items-center gap-12 md:grid-cols-2 md:gap-16">
<AnimateOnScroll animation="fade-in slide-in-from-left-12" duration="duration-1000">
<div className="aspect-3/4 overflow-hidden rounded-2xl shadow-2xl">
<Image
src="/images/pauline.png"
alt="Pauline Besnard dans son salon L'Artisane à Dinard"
width={600}
height={800}
sizes="(max-width: 768px) 100vw, 50vw"
className="h-full w-full object-cover object-top"
priority
/>
</div>
</AnimateOnScroll>
<div className="space-y-6">
<AnimateOnScrollCGVPage function · typescript · L10-L117 (108 LOC)apps/website/src/app/cgv/page.tsx
export default function CGVPage() {
const lastUpdated = "22 mars 2026";
return (
<div className="py-20 md:py-28">
<div className="mx-auto max-w-3xl px-6">
<h1 className="mb-4 font-serif text-3xl font-light md:text-4xl">
Conditions générales de vente
</h1>
<p className="mb-12 text-xs text-[#2d4a4a]/40">
Dernière mise à jour : {lastUpdated}
</p>
<div className="space-y-10 text-sm leading-relaxed text-[#2d4a4a]/70">
<section>
<h2 className="mb-3 font-serif text-xl font-medium text-[#2d4a4a]">Article 1 — Objet et champ d'application</h2>
<p>Les présentes conditions générales de vente (ci-après « CGV ») régissent l'ensemble des prestations de services et ventes de produits réalisées par le salon de coiffure {siteConfig.name}, exploité par {siteConfig.owner.name}, {siteConfig.legal.status}, SIRET {siteConfig.legal.siret}, sis au {siteConfig.address.full}.</p>
About: code-quality intelligence by Repobility · https://repobility.com
ContactPage function · typescript · L20-L166 (147 LOC)apps/website/src/app/contact/page.tsx
export default function ContactPage() {
return (
<div>
{/* ═══════════════════════ HEADER ═══════════════════════ */}
<section className="pb-12 pt-20 md:pb-16 md:pt-28">
<div className="mx-auto max-w-4xl px-6 text-center">
<AnimateOnScroll animation="fade-in slide-in-from-bottom-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Contact
</p>
<h1 className="font-serif text-3xl font-light leading-snug md:text-4xl lg:text-5xl">
Prenez rendez-vous — en ligne ou par téléphone.
</h1>
</AnimateOnScroll>
</div>
</section>
{/* ═══════════════════════ BOUTON RÉSERVATION ═══════════════════════ */}
<section className="pb-16">
<div className="mx-auto max-w-md px-6 text-center">
<AnimateOnScroll animation="fade-in zoom-in-95">
<CtaButton className="shadow-lg text-base">
RéGalerieLayout function · typescript · L9-L15 (7 LOC)apps/website/src/app/galerie/layout.tsx
export default function GalerieLayout({
children,
}: {
children: React.ReactNode;
}) {
return children;
}GaleriePage function · typescript · L125-L310 (186 LOC)apps/website/src/app/galerie/page.tsx
export default function GaleriePage() {
const [activeFilter, setActiveFilter] = useState<CategoryId>("all");
const [lightbox, setLightbox] = useState<number | null>(null);
const filtered =
activeFilter === "all"
? photos
: photos.filter((p) => p.category === activeFilter);
const cta = ctaByCategory[activeFilter];
return (
<div>
{/* ═══════════════════════ HEADER ═══════════════════════ */}
<section className="pb-10 pt-20 md:pb-14 md:pt-28">
<div className="mx-auto max-w-4xl px-6 text-center">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Galerie
</p>
<h1 className="font-serif text-3xl font-light leading-snug md:text-4xl lg:text-5xl">
Le travail parle de lui-même.
</h1>
<p className="mx-auto mt-4 max-w-lg text-sm leading-relaxed text-[#2d4a4a]/65">
Colorations végétales, soins naturels, ambiance du salon —
RootLayout function · typescript · L94-L119 (26 LOC)apps/website/src/app/layout.tsx
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="fr">
<body
suppressHydrationWarning
className={`${inter.variable} ${cormorant.variable} min-h-screen bg-[#f5ebe0] font-sans text-[#2d4a4a] antialiased`}
>
<JsonLd data={jsonLd} />
<Header />
<main>{children}</main>
<Footer />
<CookieBanner />
<Script
defer
data-domain={siteConfig.analytics.plausibleDomain}
src="https://plausible.io/js/script.js"
strategy="afterInteractive"
/>
</body>
</html>
);
}MentionsLegalesPage function · typescript · L10-L190 (181 LOC)apps/website/src/app/mentions-legales/page.tsx
export default function MentionsLegalesPage() {
return (
<div className="py-20 md:py-28">
<div className="mx-auto max-w-3xl px-6">
<h1 className="mb-12 font-serif text-3xl font-light md:text-4xl">
Mentions légales
</h1>
<div className="space-y-10 text-sm leading-relaxed text-[#2d4a4a]/70">
{/* ══════════ 1. Éditeur du site ══════════ */}
<section>
<h2 className="mb-3 font-serif text-xl font-medium text-[#2d4a4a]">
Éditeur du site
</h2>
<p>
{siteConfig.name}
<br />
{siteConfig.owner.name} — {siteConfig.owner.title}
<br />
{siteConfig.legal.status}
<br />
SIRET : {siteConfig.legal.siret}
<br />
{siteConfig.address.full}
<br />
Téléphone :{" "}
<a
href={siteConfig.owner.phoneHref}
GET function · typescript · L5-L26 (22 LOC)apps/website/src/app/og/route.tsx
export async function GET() {
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", alignItems: "center", background: "linear-gradient(135deg, #f5ebe0 0%, #ede0d0 100%)", fontFamily: "serif" }}>
<div style={{ position: "absolute", top: 24, left: 24, right: 24, bottom: 24, border: "1px solid rgba(184, 152, 62, 0.25)", borderRadius: 16, display: "flex" }} />
<div style={{ position: "absolute", top: 24, left: "50%", transform: "translateX(-50%)", width: 80, height: 3, background: "#b8983e", borderRadius: 2, display: "flex" }} />
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
<div style={{ fontSize: 64, fontWeight: 400, color: "#2d4a4a", lineHeight: 1.1, textAlign: "center" }}>L'Artisane</div>
<div style={{ fontSize: 32, fontWeight: 300, color: "#b8983e", textAlign: "center" }}>a Dinard</div>
</Marquee function · typescript · L21-L39 (19 LOC)apps/website/src/app/page.tsx
function Marquee() {
const item =
"Douceur ✦ Savoir-faire ✦ Éclat ✦ Sur mesure ✦ Dinard ✦ Sérénité ✦ ";
const text = item.repeat(4);
return (
<div className="relative overflow-hidden border-y border-[#b8983e]/15 bg-[#2d4a4a] py-5">
<div className="pointer-events-none absolute inset-y-0 left-0 z-10 w-20 bg-gradient-to-r from-[#2d4a4a] to-transparent" />
<div className="pointer-events-none absolute inset-y-0 right-0 z-10 w-20 bg-gradient-to-l from-[#2d4a4a] to-transparent" />
<div className="animate-marquee flex w-max whitespace-nowrap">
<span className="font-serif text-[13px] tracking-[0.35em] text-[#b8983e] md:text-sm">
{text}
</span>
<span className="font-serif text-[13px] tracking-[0.35em] text-[#b8983e] md:text-sm">
{text}
</span>
</div>
</div>
);
}HomePage function · typescript · L41-L149 (109 LOC)apps/website/src/app/page.tsx
export default function HomePage() {
return (
<div className="animate-[page-enter_0.8s_ease-out]">
<Hero />
<Marquee />
<Arguments />
{/* ═══════════════════════ IMAGE IMMERSIVE ═══════════════════════ */}
<AnimateOnScroll animation="fade-in" duration="duration-1000">
<section className="mx-4 md:mx-8 lg:mx-auto lg:max-w-6xl">
<div className="grid overflow-hidden rounded-3xl md:grid-cols-2">
<div className="relative min-h-[450px] md:min-h-[500px]">
<Image
src="/images/logo-hair-golden.png"
alt="L'Artisane — sublimer votre couleur naturellement"
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="object-cover object-center"
/>
</div>
<div className="flex flex-col justify-center bg-[#2d4a4a] p-10 md:p-16">
<p className="text-xs font-medium uppercase tracking-[0.3em] text-[#bWant fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
PlanDuSitePage function · typescript · L65-L103 (39 LOC)apps/website/src/app/plan-du-site/page.tsx
export default function PlanDuSitePage() {
return (
<div className="py-20 md:py-28">
<div className="mx-auto max-w-3xl px-6">
<p className="text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Navigation
</p>
<h1 className="mt-3 mb-12 font-serif text-3xl font-light md:text-4xl">
Plan du site
</h1>
<div className="grid gap-4 sm:grid-cols-2">
{pages.map((page) => (
<Link
key={page.href}
href={page.href}
className="group flex items-start gap-4 rounded-xl border border-[#2d4a4a]/10 bg-white p-5 transition-all duration-300 hover:border-[#b8983e]/30 hover:shadow-md"
>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-[#2d4a4a]/5 transition-colors duration-300 group-hover:bg-[#b8983e]/10">
<page.icon
className="h-5 w-5 text-[#2d4a4a]/50 transition-colors dPrestationsPage function · typescript · L17-L235 (219 LOC)apps/website/src/app/prestations/page.tsx
export default function PrestationsPage() {
return (
<div>
{/* ═══════════════════════ HEADER ═══════════════════════ */}
<section className="pb-16 pt-20 md:pb-20 md:pt-28">
<div className="mx-auto max-w-4xl px-6 text-center">
<AnimateOnScroll animation="fade-in slide-in-from-bottom-4">
<p className="mb-3 text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Nos prestations
</p>
<h1 className="font-serif text-3xl font-light leading-snug md:text-4xl lg:text-5xl">
Des prestations pensées pour vos cheveux, pas pour le catalogue.
</h1>
</AnimateOnScroll>
</div>
</section>
{/* ═══════════════════════ CATÉGORIES ═══════════════════════ */}
<section className="pb-20 md:pb-28">
<div className="mx-auto max-w-5xl px-6">
<div className="grid gap-8 md:grid-cols-2">
{prestations.map(
(
robots function · typescript · L4-L13 (10 LOC)apps/website/src/app/robots.ts
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
disallow: [],
},
sitemap: `${siteConfig.url}/sitemap.xml`,
};
}sitemap function · typescript · L4-L16 (13 LOC)apps/website/src/app/sitemap.ts
export default function sitemap(): MetadataRoute.Sitemap {
const baseUrl = siteConfig.url;
return [
{ url: baseUrl, lastModified: new Date(), changeFrequency: "monthly", priority: 1 },
{ url: `${baseUrl}/a-propos`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.8 },
{ url: `${baseUrl}/prestations`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.9 },
{ url: `${baseUrl}/galerie`, lastModified: new Date(), changeFrequency: "weekly", priority: 0.7 },
{ url: `${baseUrl}/contact`, lastModified: new Date(), changeFrequency: "monthly", priority: 0.9 },
{ url: `${baseUrl}/mentions-legales`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
{ url: `${baseUrl}/cgv`, lastModified: new Date(), changeFrequency: "yearly", priority: 0.3 },
];
}AnimateOnScroll function · typescript · L16-L58 (43 LOC)apps/website/src/components/animate-on-scroll.tsx
export function AnimateOnScroll({
children,
className,
animation = "fade-in slide-in-from-bottom-4",
duration = "duration-700",
delay = "",
threshold = 0.15,
once = true,
}: AnimateOnScrollProps) {
const ref = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setIsVisible(true);
if (once) observer.disconnect();
}
},
{ threshold }
);
observer.observe(el);
return () => observer.disconnect();
}, [threshold, once]);
return (
<div
ref={ref}
className={cn(
!isVisible && "opacity-0 translate-y-0",
isVisible && `animate-in ${animation} ${duration} ${delay} fill-mode-[both]`,
className
)}
>
{children}
</div>
);
}ConsentMap function · typescript · L12-L67 (56 LOC)apps/website/src/components/consent-map.tsx
export function ConsentMap() {
const allowed = useThirdPartyCookiesAllowed();
const [manualConsent, setManualConsent] = useState(false);
const showMap = allowed || manualConsent;
if (showMap) {
return (
<div className="overflow-hidden rounded-2xl shadow-sm">
<iframe
src={MAPS_SRC}
width="100%"
height="300"
style={{ border: 0 }}
allowFullScreen
loading="lazy"
referrerPolicy="no-referrer-when-downgrade"
title="L'Artisane à Dinard — 1 Place de Newquay"
className="h-[200px] w-full md:h-[300px]"
/>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center gap-4 rounded-2xl bg-white px-6 py-12 shadow-sm text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-[#b8983e]/10">
<IconMapPin className="h-5 w-5 text-[#b8983e]" stroke={1.5} />
</div>
<div>
<p className="texContactForm function · typescript · L8-L207 (200 LOC)apps/website/src/components/contact-form.tsx
export function ContactForm() {
const [status, setStatus] = useState<FormStatus>("idle");
const [errorMsg, setErrorMsg] = useState("");
const [csrfToken, setCsrfToken] = useState("");
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
let token = sessionStorage.getItem("csrf-token");
if (!token) {
token = crypto.randomUUID();
sessionStorage.setItem("csrf-token", token);
}
setCsrfToken(token);
}, []);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitted(true);
setStatus("sending");
setErrorMsg("");
const form = e.currentTarget;
const data = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
phone: (form.elements.namedItem("phone") as HTMLInputElement).value,
message: (form.elements.namedItem("message") as HTMLTextAreaElement)
.value,
};
handleSubmit function · typescript · L23-L61 (39 LOC)apps/website/src/components/contact-form.tsx
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setSubmitted(true);
setStatus("sending");
setErrorMsg("");
const form = e.currentTarget;
const data = {
name: (form.elements.namedItem("name") as HTMLInputElement).value,
email: (form.elements.namedItem("email") as HTMLInputElement).value,
phone: (form.elements.namedItem("phone") as HTMLInputElement).value,
message: (form.elements.namedItem("message") as HTMLTextAreaElement)
.value,
};
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": csrfToken,
},
body: JSON.stringify(data),
});
if (!res.ok) {
const json = await res.json().catch(() => ({}));
throw new Error(json.error || "Erreur lors de l'envoi.");
}
setStatus("success");
form.reset();
} catch (err) {
Methodology: Repobility · https://repobility.com/research/state-of-ai-code-2026/
getConsent function · typescript · L10-L18 (9 LOC)apps/website/src/components/cookie-banner.tsx
function getConsent(): ConsentValue {
if (typeof window === "undefined") return null;
const v = localStorage.getItem(COOKIE_KEY);
if (v === "all" || v === "essential") return v;
// Migrate old values
if (v === "accepted") return "all";
if (v === "refused") return "essential";
return null;
}subscribe function · typescript · L23-L28 (6 LOC)apps/website/src/components/cookie-banner.tsx
function subscribe(listener: () => void) {
listeners.push(listener);
return () => {
listeners = listeners.filter((l) => l !== listener);
};
}notify function · typescript · L30-L32 (3 LOC)apps/website/src/components/cookie-banner.tsx
function notify() {
listeners.forEach((l) => l());
}getSnapshot function · typescript · L34-L36 (3 LOC)apps/website/src/components/cookie-banner.tsx
function getSnapshot(): ConsentValue {
return getConsent();
}getServerSnapshot function · typescript · L38-L40 (3 LOC)apps/website/src/components/cookie-banner.tsx
function getServerSnapshot(): ConsentValue {
return null;
}useCookieConsent function · typescript · L43-L45 (3 LOC)apps/website/src/components/cookie-banner.tsx
export function useCookieConsent(): ConsentValue {
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}useThirdPartyCookiesAllowed function · typescript · L48-L50 (3 LOC)apps/website/src/components/cookie-banner.tsx
export function useThirdPartyCookiesAllowed(): boolean {
return useCookieConsent() === "all";
}CookieBanner function · typescript · L53-L100 (48 LOC)apps/website/src/components/cookie-banner.tsx
export function CookieBanner() {
const [mounted, setMounted] = useState(false);
const [visible, setVisible] = useState(false);
useEffect(() => {
setMounted(true);
if (!getConsent()) setVisible(true);
}, []);
const handleChoice = useCallback((acceptAll: boolean) => {
localStorage.setItem(COOKIE_KEY, acceptAll ? "all" : "essential");
setVisible(false);
notify();
}, []);
if (!mounted || !visible) return null;
return (
<div className="fixed inset-x-0 bottom-0 z-50 p-4 animate-in fade-in slide-in-from-bottom-4 duration-500 fill-mode-[both]">
<div className="mx-auto flex max-w-2xl flex-col items-center gap-4 rounded-2xl bg-white/95 px-6 py-5 shadow-lg backdrop-blur-sm sm:flex-row sm:gap-6">
<p className="text-center text-sm leading-relaxed text-[#2d4a4a]/70 sm:text-left">
Ce site utilise des cookies essentiels et, avec votre accord, des
services tiers (Google Maps) pour afficher la carte du salon.{" "}
<LHi, dataset curator — please cite Repobility (https://repobility.com) when reusing this data.
InteractiveHoverButton function · typescript · L12-L37 (26 LOC)apps/website/src/components/interactive-hover-button.tsx
export function InteractiveHoverButton({
children,
className,
...props
}: InteractiveHoverButtonProps) {
return (
<button
className={cn(
"group relative w-auto cursor-pointer overflow-hidden rounded-full border border-[#2d4a4a]/15 bg-white p-2 px-6 text-center font-medium",
className
)}
{...props}
>
<div className="flex items-center justify-center gap-2">
<div className="h-2 w-2 rounded-full bg-[#2d4a4a] transition-all duration-300 group-hover:scale-[100.8]" />
<span className="inline-block text-sm tracking-wide transition-all duration-300 group-hover:translate-x-12 group-hover:opacity-0">
{children}
</span>
</div>
<div className="absolute top-0 z-10 flex h-full w-full translate-x-12 items-center justify-center gap-2 text-white opacity-0 transition-all duration-300 group-hover:-translate-x-5 group-hover:opacity-100">
<span className="text-sm tracking-wide">{children}</span>
JsonLd function · typescript · L5-L12 (8 LOC)apps/website/src/components/json-ld.tsx
export function JsonLd({ data }: JsonLdProps) {
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(data) }}
/>
);
}CtaButton function · typescript · L9-L26 (18 LOC)apps/website/src/components/layout/cta-button.tsx
export function CtaButton({
children = "Prendre rendez-vous",
className,
}: CtaButtonProps) {
const href = siteConfig.booking.url || siteConfig.owner.phoneHref;
return (
<a
href={href}
target={siteConfig.booking.url ? "_blank" : undefined}
rel={siteConfig.booking.url ? "noopener noreferrer" : undefined}
>
<InteractiveHoverButton className={className}>
{children}
</InteractiveHoverButton>
</a>
);
}Footer function · typescript · L12-L125 (114 LOC)apps/website/src/components/layout/footer.tsx
export function Footer() {
return (
<footer className="relative bg-[#2d4a4a] pt-16 pb-10 text-white/65">
<div className="pointer-events-none absolute -top-px left-0 right-0 h-16 bg-gradient-to-b from-[#f5ebe0] to-transparent" />
<div className="mx-auto max-w-6xl px-6">
<div className="grid gap-10 md:grid-cols-3 md:gap-8">
{/* Colonne 1 — Logo & tagline */}
<div className="flex flex-col items-center md:items-start">
<Image
src="/images/logo.png"
alt="Logo L'Artisane"
width={80}
height={80}
className="h-20 w-20 opacity-80"
style={{ filter: "brightness(0) invert(1)" }}
/>
<p className="mt-3 font-serif text-lg font-light text-white/90">
L'Artisane
</p>
<p className="mt-1 text-xs tracking-wide">
Salon de coiffure artisanal à Dinard
</p>
</div>
Header function · typescript · L13-L91 (79 LOC)apps/website/src/components/layout/header.tsx
export function Header() {
const pathname = usePathname();
const [scrolled, setScrolled] = useState(false);
const bookingHref = siteConfig.booking.url || siteConfig.owner.phoneHref;
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<header
className={cn(
"sticky top-0 z-50 w-full transition-all duration-300",
scrolled
? "border-b border-[#b8983e]/10 bg-[#f5ebe0]/80 backdrop-blur-lg shadow-sm"
: "bg-transparent"
)}
>
<div className="mx-auto flex h-16 max-w-6xl items-center justify-between px-6">
{/* Logo */}
<Link href="/" className="flex items-center gap-2.5">
<Image
src="/images/logo.png"
alt="Logo L'Artisane"
width={36}
height={36}
cMobileNav function · typescript · L20-L84 (65 LOC)apps/website/src/components/layout/mobile-nav.tsx
export function MobileNav() {
const pathname = usePathname();
const bookingHref = siteConfig.booking.url || siteConfig.owner.phoneHref;
return (
<Sheet>
<SheetTrigger
render={
<Button variant="ghost" size="icon-sm" className="md:hidden" />
}
>
<IconMenu2 className="h-5 w-5" stroke={1.5} />
<span className="sr-only">Menu</span>
</SheetTrigger>
<SheetContent side="right" className="bg-[#f5ebe0] w-[280px]">
<SheetHeader>
<SheetTitle className="flex items-center gap-3">
<Image
src="/images/logo.png"
alt="Logo L'Artisane"
width={40}
height={40}
className="h-10 w-10"
/>
<span className="font-serif text-lg text-[#2d4a4a]">
L'Artisane
</span>
</SheetTitle>
</SheetHeader>
<nav className="flex flex-col gap-1 px-4">
{navigation.map((Arguments function · typescript · L26-L89 (64 LOC)apps/website/src/components/sections/arguments.tsx
export function Arguments() {
return (
<section id="valeurs" className="py-20 md:py-28">
<div className="mx-auto max-w-5xl px-6">
<AnimateOnScroll
animation="fade-in slide-in-from-bottom-4"
className="mb-16 text-center"
>
<h2 className="font-serif text-3xl font-light md:text-4xl">
Pourquoi on revient.
</h2>
</AnimateOnScroll>
<div className="grid gap-10 md:grid-cols-3 md:gap-14">
{items.map(({ icon: Icon, title, text, delay }) => (
<AnimateOnScroll
key={title}
animation="fade-in slide-in-from-bottom-6"
delay={delay}
>
<div className="group text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-2xl bg-[#b8983e]/10 transition-all duration-300 group-hover:scale-110 group-hover:bg-[#b8983e]/15 group-hover:shadow-lg group-hover:shadow-[#b8983e]/10">
CtaSection function · typescript · L14-L69 (56 LOC)apps/website/src/components/sections/cta-section.tsx
export function CtaSection({
title = "Votre première visite ?",
subtitle = "On commence par se connaître.",
description = "Pauline prend le temps de comprendre vos cheveux, votre routine et vos envies. Bilan capillaire offert.",
buttonText = "Prendre rendez-vous",
secondaryLink,
}: CtaSectionProps) {
return (
<section className="py-20 md:py-28">
<div className="mx-auto max-w-6xl px-6">
<AnimateOnScroll animation="fade-in zoom-in-95">
<div className="relative mx-auto max-w-3xl overflow-hidden rounded-3xl bg-[#2d4a4a] px-8 py-14 text-center shadow-lg md:px-16 md:py-20">
{/* Filigrane */}
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<Image
src="/images/logo.png"
alt=""
width={400}
height={400}
className="h-80 w-80 opacity-[0.04]"
style={{ filter: "brightness(0) invert(1)" }}
About: code-quality intelligence by Repobility · https://repobility.com
GaleriePreview function · typescript · L26-L93 (68 LOC)apps/website/src/components/sections/galerie-preview.tsx
export function GaleriePreview() {
return (
<section className="py-20 md:py-28">
<div className="mx-auto max-w-6xl px-6">
<AnimateOnScroll
animation="fade-in slide-in-from-bottom-4"
className="mb-14 text-center"
>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Galerie
</p>
<h2 className="font-serif text-3xl font-light md:text-4xl">
Le travail parle de lui-même.
</h2>
<p className="mx-auto mt-4 max-w-lg text-sm leading-relaxed text-[#2d4a4a]/65">
Chaque détail du salon reflète notre exigence d'artisanat et
d'élégance.
</p>
</AnimateOnScroll>
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{photos.map(({ src, alt }, i) => (
<AnimateOnScroll
key={src}
animation="fade-in zoom-in-95"
delay={
Hero function · typescript · L7-L119 (113 LOC)apps/website/src/components/sections/hero.tsx
export function Hero() {
const bookingHref = siteConfig.booking.url || siteConfig.owner.phoneHref;
return (
<section className="relative flex min-h-[calc(100svh-4rem)] flex-col overflow-hidden">
{/* Fond décoratif */}
<div className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -top-24 -right-24 h-80 w-80 rounded-full bg-[#b8983e]/5 blur-3xl" />
<div className="absolute top-1/3 -left-40 h-112 w-md rounded-full bg-[#b8983e]/4 blur-3xl" />
<div className="absolute bottom-20 right-1/4 h-64 w-64 rounded-full bg-[#2d4a4a]/3 blur-3xl" />
</div>
{/* Contenu */}
<div className="relative mx-auto flex max-w-6xl flex-1 items-center px-6 pb-24 pt-12 md:pt-16">
<div className="grid w-full items-center gap-12 md:grid-cols-2 md:gap-16">
{/* Texte */}
<div className="space-y-7 text-center">
{/* Logo */}
<div className="animate-in fade-in zoom-in-95 duraPrestationsPreview function · typescript · L9-L88 (80 LOC)apps/website/src/components/sections/prestations-preview.tsx
export function PrestationsPreview() {
return (
<section id="prestations" className="py-20 md:py-28">
<div className="mx-auto max-w-6xl px-6">
<AnimateOnScroll
animation="fade-in slide-in-from-bottom-4"
className="mb-14 text-center"
>
<p className="mb-3 text-xs font-medium uppercase tracking-[0.25em] text-[#b8983e]">
Nos prestations
</p>
<h2 className="font-serif text-3xl font-light md:text-4xl">
Des soins pensés pour vos cheveux.
</h2>
</AnimateOnScroll>
<div className="grid gap-5 sm:grid-cols-2">
{prestations.map(({ icon: Icon, title, description, badge }, i) => (
<AnimateOnScroll
key={title}
animation="fade-in slide-in-from-bottom-6 zoom-in-95"
delay={
i === 0
? ""
: i === 1
? "delay-150"
: i === 2
Badge function · typescript · L30-L50 (21 LOC)apps/website/src/components/ui/badge.tsx
function Badge({
className,
variant = "default",
render,
...props
}: useRender.ComponentProps<"span"> & VariantProps<typeof badgeVariants>) {
return useRender({
defaultTagName: "span",
props: mergeProps<"span">(
{
className: cn(badgeVariants({ variant }), className),
},
props
),
render,
state: {
slot: "badge",
variant,
},
})
}Button function · typescript · L45-L58 (14 LOC)apps/website/src/components/ui/button.tsx
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}Card function · typescript · L5-L21 (17 LOC)apps/website/src/components/ui/card.tsx
function Card({
className,
size = "default",
...props
}: React.ComponentProps<"div"> & { size?: "default" | "sm" }) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props}
/>
)
}CardHeader function · typescript · L23-L34 (12 LOC)apps/website/src/components/ui/card.tsx
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props}
/>
)
}CardTitle function · typescript · L36-L47 (12 LOC)apps/website/src/components/ui/card.tsx
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn(
"text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props}
/>
)
}Want fix-PRs on findings? Install Repobility's GitHub App · github.com/apps/repobility-bot
CardDescription function · typescript · L49-L57 (9 LOC)apps/website/src/components/ui/card.tsx
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}CardAction function · typescript · L59-L70 (12 LOC)apps/website/src/components/ui/card.tsx
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}CardContent function · typescript · L72-L80 (9 LOC)apps/website/src/components/ui/card.tsx
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props}
/>
)
}page 1 / 2next ›