← back to djanni974__l-Artisane-a-dinard

Function bodies 74 total

All specs Real LLM only Function bodies
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, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&#39;");
}
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 N
AProposPage 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">
              <AnimateOnScroll
CGVPage 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&apos;application</h2>
            <p>Les présentes conditions générales de vente (ci-après &laquo; CGV &raquo;) régissent l&apos;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&apos;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-[#b
Want 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 d
PrestationsPage 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="tex
ContactForm 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.{" "}
          <L
Hi, 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&apos;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}
            c
MobileNav 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&apos;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&apos;artisanat et
            d&apos;é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 dura
PrestationsPreview 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 ›