// linktree.js — shared React components that render the public linktree.
// Used by view.html (read-only public profile) and edit.html (live preview).
// Mirrors the original stanmaxx.html design 1:1; the only deltas are:
//   • Photo + bubble thumb accept image URLs (uploaded files) and fall back to
//     the original SVG placeholders when nothing is uploaded yet.
//   • Cover style is locked to "stack" (per product spec — no toggle).
//   • Bubble is locked to top-right rounded (per product spec).
//   • Motion is locked to "lively" (~18s drift cycle).
// Exposes window.Linktree = { App, Background, Header, Identity, EventCard,
// Links, ProfileCover, BubbleSticker, PhotoPortrait, PhotoSecondary, LinkIcon }
// so other scripts can mount a preview or just reuse a piece.

const { useState, useEffect, useRef, useCallback } = React;

// ── i18n helpers ────────────────────────────────────────────────────────────
// Linktree components are mounted both on the public profile (view.html) and
// inside the editor preview (edit.html). On view.html the owner's locale is
// applied to window.i18n before this code runs. On edit.html the editor's
// own locale (= the visitor's = the owner's) is already set. So we just call
// window.i18n.t() directly.
//
// useI18n is a tiny hook that re-renders the component when the language
// changes (the i18n module dispatches "i18n:change" on window). Without it,
// React wouldn't know to re-render translated strings after setLang().
function useI18n() {
  const [, setTick] = useState(0);
  useEffect(() => {
    const onChange = () => setTick((n) => n + 1);
    window.addEventListener("i18n:change", onChange);
    return () => window.removeEventListener("i18n:change", onChange);
  }, []);
  return useCallback((key, params, fallback) => {
    if (typeof window !== "undefined" && window.i18n) {
      const v = window.i18n.t(key, params);
      if (v && v !== key) return v;
    }
    return fallback != null ? fallback : key;
  }, []);
}

// ── Background ──────────────────────────────────────────────────────────────
function Background() {
  return (
    <div className="bg" aria-hidden="true">
      <div className="bg-base" />
      <div className="bg-blob bg-blob-a" />
      <div className="bg-blob bg-blob-b" />
      <div className="bg-blob bg-blob-c" />
      {/* Optional flat-colour layer. It sits above the gradient + blobs but
          below the grain, so when the owner picks a background colour it
          paints over the gradient (leaving the gradient untouched, ready to
          come back the moment the colour is cleared) while keeping the same
          subtle grain texture on top. Driven purely by CSS variables set in
          LinktreeApp — transparent / 0 opacity when no colour is chosen. */}
      <div className="bg-solid" />
      <div className="bg-grain" />
    </div>
  );
}

// ── Header ──────────────────────────────────────────────────────────────────
function Header({ rightSlot, shareUrl, housePseudo, houseEnabled, markHref }) {
  const [open, setOpen] = useState(false);
  const [copied, setCopied] = useState(false);
  const [error, setError] = useState(false);
  const wrapRef = useRef(null);
  const resetRef = useRef(null);
  const t = useI18n();

  // The URL we share. Prefer an explicit shareUrl (passed by LinktreeApp so
  // the editor's preview points at /pseudo and not /edit); fall back to the
  // current page when none is provided (public profile view).
  const url = shareUrl
    || (typeof location !== "undefined" ? location.href : "");

  // Click-outside + Escape close the popover.
  useEffect(() => {
    if (!open) return;
    const onDown = (e) => {
      if (wrapRef.current && !wrapRef.current.contains(e.target)) setOpen(false);
    };
    const onKey = (e) => { if (e.key === "Escape") setOpen(false); };
    document.addEventListener("mousedown", onDown);
    document.addEventListener("touchstart", onDown, { passive: true });
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("mousedown", onDown);
      document.removeEventListener("touchstart", onDown);
      document.removeEventListener("keydown", onKey);
    };
  }, [open]);

  const flash = (which) => {
    if (which === "copied") { setCopied(true); setError(false); }
    else                    { setError(true); setCopied(false); }
    if (resetRef.current) clearTimeout(resetRef.current);
    resetRef.current = setTimeout(() => {
      setCopied(false); setError(false);
    }, 1800);
  };

  const doCopy = async () => {
    try {
      if (navigator.clipboard) {
        await navigator.clipboard.writeText(url);
        flash("copied");
      } else {
        // Legacy fallback for older browsers.
        const ta = document.createElement("textarea");
        ta.value = url;
        ta.style.position = "fixed";
        ta.style.opacity = "0";
        document.body.appendChild(ta);
        ta.select();
        try { document.execCommand("copy"); flash("copied"); }
        catch { flash("error"); }
        document.body.removeChild(ta);
      }
    } catch {
      flash("error");
    }
  };

  const doNativeShare = async () => {
    if (!navigator.share) { doCopy(); return; }
    try {
      await navigator.share({ url, title: document.title || "stanmaxx" });
      setOpen(false);
    } catch (e) {
      // User dismissed the native sheet — leave popover open silently.
      if (e && e.name !== "AbortError") flash("error");
    }
  };

  const canNativeShare = typeof navigator !== "undefined" && !!navigator.share;

  return (
    <header className="header">
      <a className="header-mark" href={markHref || "/"} aria-label="stanmaxx.com">
        <span className="dot" />
        <span className="header-mark-name">
          {t("linktree.made_with", null, "cr\u00e9\u00e9 avec stanmaxx")}<span className="muted">.com</span>
        </span>
      </a>
      <div className="header-actions">
        {/* House entrance — the owner enabled a companion page, so we
            offer a one-tap jump to it from the linktree header. We use
            the same glass chip recipe as the share button and keep it
            tucked left of share so the share button stays the right
            anchor visitors know. We don't show this button on the
            editor preview (rightSlot is set there), to keep the header
            from getting too crowded inside the small preview pane. */}
        {!rightSlot && houseEnabled && housePseudo && (
          <a className="header-house" href={'/' + housePseudo + '/house'}
             aria-label={t("view.visit_house", { pseudo: housePseudo },
                          "Visiter la maison de @" + housePseudo)}>
            <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
                 stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
                 strokeLinejoin="round">
              <path d="M3 11.5L12 4l9 7.5"/>
              <path d="M5 10.5V20a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-9.5"/>
              <path d="M10 21v-6h4v6"/>
            </svg>
          </a>
        )}
        {rightSlot || (
          <div className="header-share-wrap" ref={wrapRef}>
            <button className="header-share" aria-label={t("common.share_link", null, "Partager cette page")}
                    aria-haspopup="menu"
                    aria-expanded={open ? "true" : "false"}
                    onClick={() => setOpen((v) => !v)}>
              <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
                   stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
                   strokeLinejoin="round">
                <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7" />
                <path d="M16 6l-4-4-4 4" />
                <path d="M12 2v14" />
              </svg>
            </button>

            {open && (
              <div className="share-pop" role="menu">
                <div className="share-pop-title">{t("common.share_link", null, "Partager cette page")}</div>
                <div className="share-pop-url" title={url}>{url}</div>

                <button type="button"
                        className={`share-pop-primary ${copied ? "is-copied" : ""} ${error ? "is-error" : ""}`}
                        onClick={doCopy}>
                  {copied ? (
                    <>
                      <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
                           stroke="currentColor" strokeWidth="2.2"
                           strokeLinecap="round" strokeLinejoin="round">
                        <path d="M3 8.5l3.2 3L13 4.5" />
                      </svg>
                      {t("common.link_copied", null, "Lien copié")}
                    </>
                  ) : error ? (
                    t("common.error_retry", null, "Erreur — réessayer")
                  ) : (
                    <>
                      <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
                           stroke="currentColor" strokeWidth="1.8"
                           strokeLinecap="round" strokeLinejoin="round">
                        <rect x="5" y="5" width="8" height="9" rx="1.5" />
                        <path d="M3 11V3.5A1.5 1.5 0 0 1 4.5 2H10" />
                      </svg>
                      {t("common.copy_link", null, "Copier le lien")}
                    </>
                  )}
                </button>

                {canNativeShare && (
                  <button type="button"
                          className="share-pop-secondary"
                          onClick={doNativeShare}>
                    <svg viewBox="0 0 24 24" width="13" height="13" fill="none"
                         stroke="currentColor" strokeWidth="1.8"
                         strokeLinecap="round" strokeLinejoin="round">
                      <path d="M4 12v7a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1v-7" />
                      <path d="M16 6l-4-4-4 4" />
                      <path d="M12 2v14" />
                    </svg>
                    {t("common.more_options", null, "Plus d'options (apps)")}
                  </button>
                )}
              </div>
            )}
          </div>
        )}
      </div>
    </header>
  );
}

// ── ProfileCover ────────────────────────────────────────────────────────────
// Square Liquid-Glass frame holding the profile photo, with a corner sticker
// (BubbleSticker). Click the sticker → expand its mini photo to fill the
// cover; click anywhere to collapse. The cover is locked to "stack" style
// per the product spec. The bubble can be turned off by the owner
// (bubbleEnabled === false); when it is, we render the cover on its own and
// skip the sticker entirely (so there's nothing to expand the secondary photo
// either — the cover just shows the main photo).
function ProfileCover({ photoUrl, storyUrl, bubbleText, expanded, onToggle, bubbleEnabled = true }) {
  // The cover has two faces: the photo, and the story/bubble behind it. Only
  // allow the flip when the back actually holds something. Without this check
  // a tap swapped a real photo for an empty placeholder — which reads exactly
  // like "my picture stopped loading", complete with the grey wash of the
  // placeholder art showing through the gloss layer.
  const hasBack = !!storyUrl || !!(bubbleText && String(bubbleText).trim());
  const showBubble = bubbleEnabled !== false && hasBack;
  return (
    <div className="cover cover-stack">
      <div className="cover-shadow cover-shadow-2" />
      <div className="cover-shadow cover-shadow-1" />

      <div className="cover-frame">
        <div className={`cover-photo ${expanded && showBubble ? "is-hidden" : ""}`}>
          {photoUrl ? <img src={photoUrl} alt="" /> : <PhotoPortrait />}
        </div>
        <div className={`cover-photo ${expanded && showBubble ? "" : "is-hidden"}`}>
          {storyUrl ? <img src={storyUrl} alt="" /> : <PhotoSecondary />}
        </div>

        <div className="cover-gloss" aria-hidden="true" />
        <div className="cover-rim" aria-hidden="true" />
      </div>

      {showBubble && (
        <BubbleSticker
          text={bubbleText}
          storyUrl={storyUrl}
          active={expanded}
          onClick={onToggle}
        />
      )}
    </div>
  );
}

// ── BubbleSticker ───────────────────────────────────────────────────────────
// Glass speech bubble pinned to the cover's top-right corner (locked by
// product spec). Holds bubble text + a small thumbnail of the secondary
// photo. Click → tells parent to expand.
function BubbleSticker({ text, storyUrl, active, onClick }) {
  const corner = "tr";
  const shape = "rounded";
  const t = useI18n();
  return (
    <button
      type="button"
      className={`bubble bubble-${corner} bubble-${shape} ${active ? "is-active" : ""}`}
      onClick={onClick}
      aria-pressed={active}
      aria-label={active
        ? t("linktree.photo_collapse", null, "Réduire la photo")
        : t("linktree.photo_expand",   null, "Agrandir la photo")}
    >
      <div className="bubble-thumb">
        {storyUrl ? <img src={storyUrl} alt="" /> : <PhotoSecondary />}
        {active && (
          <div className="bubble-close" aria-hidden="true">
            <svg viewBox="0 0 16 16" width="12" height="12" fill="none"
                 stroke="currentColor" strokeWidth="2" strokeLinecap="round">
              <path d="M4 4l8 8M12 4l-8 8" />
            </svg>
          </div>
        )}
      </div>
      <div className="bubble-text">
        {String(text || "").split("\n").map((line, i) => (
          <span key={i}>{line || "\u00A0"}</span>
        ))}
      </div>
      <div className="bubble-tail" aria-hidden="true" />
    </button>
  );
}

// ── Identity ────────────────────────────────────────────────────────────────
function Identity({ displayName, tagline, nameColor }) {
  // The owner-picked name color (when present) overrides the default white.
  // We always keep the drop-shadow so the text reads on bright gradients
  // even with pale colors like ivory or soft pink. nameColor comes from
  // a server-enforced allowlist, so it's safe to inject as a CSS color.
  // Expose the choice as a CSS variable too: themes that paint the name
  // with a gradient (Y2K uses -webkit-text-fill-color, which beats plain
  // `color`) can then honor an explicit pick via this var. Plain `color`
  // still covers every other theme.
  const nameStyle = nameColor ? { color: nameColor, "--name-color": nameColor } : undefined;
  return (
    <div className="identity">
      <h1 className="identity-name">
        <span className="identity-given" style={nameStyle}>{displayName}</span>
      </h1>
      {tagline && <p className="identity-tag">{tagline}</p>}
    </div>
  );
}

// ── HeartButton ──────────────────────────────────────────────────────────────
// "Leave a heart" on a link page. Anyone can tap it — signed in or not — and
// the server keeps one heart per visitor (toggleable). It reads its initial
// count from the profile payload, then asks the server whether *this* visitor
// already hearted (so the icon shows filled on return visits). In the editor
// preview it renders read-only (interactive=false): it shows the count without
// pinging the API or letting the owner heart their own page.
function HeartButton({ pseudo, initialCount = 0, interactive = true }) {
  const t = useI18n();
  const [count, setCount] = useState(initialCount || 0);
  const [mine, setMine] = useState(false);
  const [busy, setBusy] = useState(false);
  const [pop, setPop] = useState(false);

  // Keep the count in sync if the payload's number changes (e.g. live preview).
  useEffect(() => { setCount(initialCount || 0); }, [initialCount]);

  // On the public page, resolve the per-visitor state once on mount.
  useEffect(() => {
    if (!interactive || !pseudo) return;
    let alive = true;
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/heart", { credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : null))
      .then((j) => { if (alive && j) { setCount(j.count || 0); setMine(!!j.mine); } })
      .catch(() => {});
    return () => { alive = false; };
  }, [pseudo, interactive]);

  const toggle = () => {
    if (!interactive || busy || !pseudo) return;
    const next = !mine;
    setBusy(true);
    // Optimistic update so the tap feels instant.
    setMine(next);
    setCount((c) => Math.max(0, c + (next ? 1 : -1)));
    if (next) { setPop(true); setTimeout(() => setPop(false), 420); }
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/heart", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
    })
      .then((r) => (r.ok ? r.json() : Promise.reject(r.status)))
      .then((j) => { setCount(j.count || 0); setMine(!!j.mine); })
      .catch(() => {
        // Roll back on failure.
        setMine(!next);
        setCount((c) => Math.max(0, c + (next ? -1 : 1)));
      })
      .finally(() => setBusy(false));
  };

  const label = mine
    ? t("linktree.heart_remove", null, "Retirer mon cœur")
    : t("linktree.heart_add", null, "Laisser un cœur");

  return (
    <div className="heart-row">
      <button
        type="button"
        className={`heart ${mine ? "is-on" : ""} ${pop ? "is-pop" : ""} ${interactive ? "" : "is-static"}`}
        onClick={toggle}
        aria-pressed={mine}
        aria-label={label}
        title={label}
      >
        <svg className="heart-glyph" viewBox="0 0 24 24" width="18" height="18" aria-hidden="true">
          <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
        </svg>
        <span className="heart-count">{count}</span>
      </button>
    </div>
  );
}

// ── FollowButton ─────────────────────────────────────────────────────────────
// Lets signed-in visitors follow this page (their Discover "Suivis" tab then
// shows its activity). Hidden on your own page, in editor previews, and
// whenever the Discover feature flag is off (the status fetch 404s). Signed
// out, the button still shows and leads to login.
// "Message" button on a profile: starts (or reuses) a DM with the owner and
// jumps to /messages. Signed-out visitors are routed to login. Only shown
// when the owner accepts messages (data.dmOpen).
function MessageButton({ pseudo, interactive, overrides }) {
  const t = useI18n();
  const [busy, setBusy] = useState(false);
  const open = () => {
    if (!interactive || busy) return;
    setBusy(true);
    fetch("/api/messages/dm", {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pseudo }),
    })
      .then((r) => {
        if (r.status === 401) {
          window.location.href = "/login?intent=messages";
          return null;
        }
        if (r.status === 403) { setBusy(false); return null; }
        return r.ok ? r.json() : null;
      })
      .then((d) => { if (d && d.id) window.location.href = "/messages?c=" + d.id; })
      .catch(() => {})
      .finally(() => setBusy(false));
  };
  return (
    <button type="button" className="msg-btn" data-button-id="message" {...(buttonOverrideProps(overrides, "message") || {})} onClick={open} disabled={busy}
            aria-label={t("linktree.message", null, "Message")}>
      <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor"
           strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
        <path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.6L4 20l1-4.4A8.5 8.5 0 1 1 21 11.5z" />
      </svg>
      <span>{t("linktree.message", null, "Message")}</span>
    </button>
  );
}

function FollowButton({ pseudo, interactive }) {
  const t = useI18n();
  const [st, setSt] = useState(null);
  useEffect(() => {
    if (!interactive || !pseudo) return;
    fetch(`/api/u/${encodeURIComponent(pseudo)}/follow`, { credentials: "same-origin" })
      .then((r) => r.ok ? r.json() : null)
      .then((d) => { if (d) setSt(d); })
      .catch(() => {});
  }, [pseudo, interactive]);
  if (!st || st.self) return null;
  const toggle = () => {
    if (!st.authed) { window.location.href = "/login"; return; }
    const next = !st.following;
    // Optimistic flip; reconciled with the server response below.
    setSt((s) => ({ ...s, following: next, followers: Math.max(0, s.followers + (next ? 1 : -1)) }));
    fetch(`/api/u/${encodeURIComponent(pseudo)}/follow`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ follow: next }),
    })
      .then((r) => r.ok ? r.json() : null)
      .then((d) => { if (d) setSt((s) => ({ ...s, following: d.following, followers: d.followers })); })
      .catch(() => {});
  };
  return (
    <button type="button" className={`follow ${st.following ? "is-on" : ""}`}
            onClick={toggle} aria-pressed={st.following}>
      {st.following
        ? t("linktree.following", null, "Suivi")
        : t("linktree.follow", null, "Suivre")}
      {st.followers > 0 && <span className="follow-count">{st.followers}</span>}
    </button>
  );
}

// ── StanLabel ────────────────────────────────────────────────────────────────
// Owner-toggled badge shown under the profile and above the links:
// "currently stanning <name>". The name is the star/person the owner is a fan
// of (their own free text). Rendered only when enabled and non-empty (guarded
// by the caller too).
function StanLabel({ name, overrides }) {
  const t = useI18n();
  if (!name) return null;
  return (
    <div className="stan-row">
      <div className="stan-badge" data-badge-id="stan" {...(badgeOverrideProps(overrides, "stan") || {})}>
        <svg className="stan-star" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
          <path d="M12 2.6l2.7 5.9 6.4.7-4.8 4.3 1.3 6.3L12 17.9 6.4 19.8l1.3-6.3-4.8-4.3 6.4-.7L12 2.6z" />
        </svg>
        <span className="stan-text">
          <span className="stan-label">{t("linktree.stanning", null, "currently stanning")}</span>
          {" "}
          <span className="stan-name">{name}</span>
        </span>
      </div>
    </div>
  );
}

// ── LyricLine ────────────────────────────────────────────────────────────────
// Owner-toggled favourite lyric, framed by two musical notes. The text is the
// owner's own free input — we never prefill or generate it.
function LyricLine({ text }) {
  if (!text) return null;
  const Note = () => (
    <svg className="lyric-note" viewBox="0 0 24 24" width="15" height="15" aria-hidden="true">
      <path d="M9 18V5l11-2v13" fill="none" stroke="currentColor" strokeWidth="1.7"
            strokeLinecap="round" strokeLinejoin="round" />
      <circle cx="6.5" cy="18" r="2.6" fill="currentColor" />
      <circle cx="17.5" cy="16" r="2.6" fill="currentColor" />
    </svg>
  );
  return (
    <div className="lyric-row">
      <Note />
      <span className="lyric-text">{text}</span>
      <Note />
    </div>
  );
}

// ── LiftsBadge ───────────────────────────────────────────────────────────────
// Owner-toggled gym PRs. A small dumbbell pill sits under the profile; tapping
// it expands a glass card listing the lifts the owner entered, each with the
// chosen unit (kg/lbs). Rendered only when there's at least one valid entry.
function LiftsBadge({ lifts, unit, label, overrides }) {
  const t = useI18n();
  const [open, setOpen] = useState(false);
  const items = (Array.isArray(lifts) ? lifts : [])
    .filter((l) => l && l.label && (l.value || l.value === 0));
  if (items.length === 0) return null;
  const u = unit === "lbs" ? "lbs" : "kg";
  const title = t("linktree.lifts_title", null, "Mes perfs en muscu");
  return (
    <div className={`lifts ${open ? "is-open" : ""}`}>
      <button type="button" className="lifts-btn" data-button-id="lifts" {...(buttonOverrideProps(overrides, "lifts") || {})} onClick={() => setOpen((o) => !o)}
              aria-expanded={open} aria-label={title} title={title}>
        <svg className="lifts-dumbbell" viewBox="0 0 24 24" width="17" height="17" aria-hidden="true">
          <path d="M3 9v6M6 7v10M18 7v10M21 9v6M6.5 12h11"
                fill="none" stroke="currentColor" strokeWidth="2"
                strokeLinecap="round" strokeLinejoin="round" />
        </svg>
        <span className="lifts-btn-label" data-button-id="lifts">{label || t("linktree.lifts", null, "perfs")}</span>
        <svg className="lifts-caret" viewBox="0 0 24 24" width="13" height="13" aria-hidden="true">
          <path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" strokeWidth="2"
                strokeLinecap="round" strokeLinejoin="round" />
        </svg>
      </button>
      <div className="lifts-panel" role="region" aria-label={title}>
        <ul className="lifts-list">
          {items.map((l, i) => (
            <li className="lifts-item" key={i}>
              <span className="lifts-name">{l.label}</span>
              <span className="lifts-val">{l.value} {u}</span>
            </li>
          ))}
        </ul>
      </div>
    </div>
  );
}

// ── Countries (supporter badge + /pronos) ───────────────────────────────────
// Codes are ISO 3166-1 alpha-2 (plus the UK nations flagcdn supports);
// flags render from flagcdn.com. Names in French — recognizable everywhere.
const COUNTRIES = {
  fr: "France", de: "Allemagne", es: "Espagne", it: "Italie", pt: "Portugal",
  nl: "Pays-Bas", be: "Belgique", hr: "Croatie", rs: "Serbie", ch: "Suisse",
  at: "Autriche", pl: "Pologne", dk: "Danemark", se: "Su\u00e8de", no: "Norv\u00e8ge",
  ua: "Ukraine", tr: "Turquie", "gb-eng": "Angleterre", "gb-sct": "\u00c9cosse",
  "gb-wls": "Pays de Galles", ie: "Irlande", gr: "Gr\u00e8ce", cz: "Tch\u00e9quie",
  ro: "Roumanie", ma: "Maroc", sn: "S\u00e9n\u00e9gal", ci: "C\u00f4te d'Ivoire",
  ng: "Nigeria", gh: "Ghana", cm: "Cameroun", dz: "Alg\u00e9rie", tn: "Tunisie",
  eg: "\u00c9gypte", za: "Afrique du Sud", ml: "Mali", us: "\u00c9tats-Unis",
  ca: "Canada", mx: "Mexique", br: "Br\u00e9sil", ar: "Argentine", uy: "Uruguay",
  co: "Colombie", cl: "Chili", pe: "P\u00e9rou", ec: "\u00c9quateur", py: "Paraguay",
  ve: "Venezuela", pa: "Panama", cr: "Costa Rica", jp: "Japon", kr: "Cor\u00e9e du Sud",
  au: "Australie", sa: "Arabie saoudite", qa: "Qatar", ir: "Iran", uz: "Ouzb\u00e9kistan",
  jo: "Jordanie", ba: "Bosnie-Herz\u00e9govine", ht: "Ha\u00efti", cw: "Cura\u00e7ao",
  nz: "Nouvelle-Z\u00e9lande", cv: "Cap-Vert", iq: "Irak", cd: "RD Congo",
};
function countryFlagUrl(code, size) {
  return "https://flagcdn.com/" + (size || "w40") + "/" + code + ".png";
}

// ── SupporterBadge ───────────────────────────────────────────────────────────
// World-Cup supporter pill: the flag of the country the owner roots for.
// Links to /pronos so any page is one tap away from the predictions game.
function SupporterBadge({ country, interactive, overrides }) {
  const t = useI18n();
  const name = COUNTRIES[country];
  if (!name) return null;
  const inner = (
    <>
      <img className="supporter-flag" src={countryFlagUrl(country)} alt="" loading="lazy" />
      <span className="supporter-label">{t("linktree.supports", null, "supporte")}</span>
      <span className="supporter-name">{name}</span>
    </>
  );
  const ov = badgeOverrideProps(overrides, "supporter") || {};
  return (
    <div className="supporter-row">
      {interactive
        ? <a className="supporter-badge" data-badge-id="supporter" {...ov} href="/pronos" title={name}>{inner}</a>
        : <div className="supporter-badge" data-badge-id="supporter" {...ov} title={name}>{inner}</div>}
    </div>
  );
}

// ── WcTopBadge ───────────────────────────────────────────────────────────────
// Awarded automatically to the podium of the predictions leaderboard:
// gold / silver / bronze trophy pill. Links to the leaderboard.
function WcTopBadge({ rank, interactive }) {
  const t = useI18n();
  if (!rank || rank < 1 || rank > 3) return null;
  const inner = (
    <>
      <svg className="wcbadge-trophy" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
        <path d="M7 4h10v5a5 5 0 0 1-10 0V4z" fill="none" stroke="currentColor"
              strokeWidth="1.8" strokeLinejoin="round" />
        <path d="M7 5H4v2a3 3 0 0 0 3 3M17 5h3v2a3 3 0 0 1-3 3M12 14v3M8.5 20h7M10 17h4"
              fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round"
              strokeLinejoin="round" />
      </svg>
      <span className="wcbadge-label">
        {t("linktree.wc_top", null, "Top pronostiqueur")}
      </span>
      <span className="wcbadge-rank">{"n\u00ba" + rank}</span>
    </>
  );
  return (
    <div className="wcbadge-row">
      {interactive
        ? <a className={`wcbadge rank-${rank}`} href="/pronos">{inner}</a>
        : <div className={`wcbadge rank-${rank}`}>{inner}</div>}
    </div>
  );
}

// ── PronounsTag ──────────────────────────────────────────────────────────────
// Owner-toggled pronouns, as a small understated pill right under the name.
// Free text — people word their own pronouns.
function PronounsTag({ text, overrides }) {
  if (!text) return null;
  return (
    <div className="pronouns-row">
      <span className="pronouns-tag" data-badge-id="pronouns" {...(badgeOverrideProps(overrides, "pronouns") || {})}>{text}</span>
    </div>
  );
}

// ── Sport icons ──────────────────────────────────────────────────────────────
// One hand-drawn line icon per whitelisted sport, shared by the public pills
// and the editor chips. 24x24 viewBox, stroked with currentColor.
const SPORT_ICONS = {
  musculation: <path d="M2.5 12h2M19.5 12h2M6 8.5v7M9.5 6.5v11M14.5 6.5v11M18 8.5v7M9.5 12h5" />,
  running: <><circle cx="12" cy="14" r="6.5" /><path d="M12 14v-3.5M9.5 3h5M12 3v2.5M17.5 8l1.5-1.5" /></>,
  football: <><circle cx="12" cy="12" r="8.5" /><path d="M12 8l3.8 2.8-1.45 4.4h-4.7L8.2 10.8zM12 8V3.5M15.8 10.8l4.3-1.3M14.35 15.2l2.6 3.6M9.65 15.2l-2.6 3.6M8.2 10.8 3.9 9.5" /></>,
  basketball: <><circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17M12 3.5v17M6 6c3.4 3.2 3.4 8.8 0 12M18 6c-3.4 3.2-3.4 8.8 0 12" /></>,
  tennis: <><circle cx="12" cy="12" r="8.5" /><path d="M4 8.5c4.8 2 11.2 2 16 0M4 15.5c4.8-2 11.2-2 16 0" /></>,
  natation: <><circle cx="8.5" cy="6" r="1.9" /><path d="M11.5 8.5l4.5 2M2.5 13.5c2 1.6 4.3 1.6 6.3 0s4.3-1.6 6.3 0 4.4 1.6 6.4 0M2.5 18.5c2 1.6 4.3 1.6 6.3 0s4.3-1.6 6.3 0 4.4 1.6 6.4 0" /></>,
  cyclisme: <><circle cx="6" cy="16.5" r="3.8" /><circle cx="18" cy="16.5" r="3.8" /><path d="M6 16.5l3.5-7h5.5l3 7M9.5 9.5h-2M12.5 16.5l2.5-7" /></>,
  boxe: <path d="M7.5 12V8.5a4.5 4.5 0 0 1 9 0v5.5a4 4 0 0 1-4 4h-2.5a3 3 0 0 1-3-3v-1.5a2 2 0 0 1 2-2h4.5M7.5 18v2.5h9V18" />,
  yoga: <path d="M12 3.5c1.6 2.2 1.6 5.3 0 7.5-1.6-2.2-1.6-5.3 0-7.5zM4.5 9c2.7.6 4.9 2.7 5.5 5.4C7.3 13.8 5.1 11.7 4.5 9zM19.5 9c-2.7.6-4.9 2.7-5.5 5.4 2.7-.6 4.9-2.7 5.5-5.4zM4 16.5c2.9 2.9 13.1 2.9 16 0" />,
  danse: <><path d="M10.6 17.5V5.5l8-2v11" /><circle cx="8" cy="17.5" r="2.6" /><circle cx="16" cy="14.5" r="2.6" /></>,
  escalade: <path d="M3 19.5 9.8 7l4 7.2 2.6-4.4L21 19.5zM9.8 7V3.5h3.5l-1 1.8-2.5.2" />,
  volleyball: <><circle cx="12" cy="12" r="8.5" /><path d="M12 3.5c-1.8 4-1.6 8.3 1.2 11.8M12 3.5c3.8.9 6.7 3.7 7.9 7.4M4 9.8c4 1.7 8.2.9 11.2-2.2M4 9.8c-1 4 .2 8 3.6 10.6" /></>,
};
function SportIcon({ sport, size }) {
  const icon = SPORT_ICONS[sport];
  if (!icon) return null;
  return (
    <svg className="sport-ico" viewBox="0 0 24 24" width={size || 14} height={size || 14}
         fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round"
         strokeLinejoin="round" aria-hidden="true">
      {icon}
    </svg>
  );
}

// ── Social icons ─────────────────────────────────────────────────────────────
// Logo-only network icons under the profile photo, Linktree-style. Types
// whitelisted server-side; simple stroked glyphs, currentColor.
// Real brand marks. Instagram through OpenSea are the official Simple Icons
// (CC0) monochrome logo paths — LinkedIn comes from Font Awesome Free
// (LinkedIn had its mark pulled from Simple Icons after a trademark
// request, hence the different source and the nested 448x512 viewBox).
// Trovo, DLive, Truth Social, Pump.fun and Magic Eden don't have a
// license-clean vector available anywhere I could verify pixel-for-pixel,
// so those five are hand-built from confirmed design descriptions of the
// real marks (see chat) rather than traced from an official source file.
const SOCIAL_ICONS = {
  instagram: <path fill="currentColor" stroke="none" d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />,
  x: <path fill="currentColor" stroke="none" d="M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258 24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076 1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.182z" />,
  tiktok: <path fill="currentColor" stroke="none" d="M12.525.02c1.31-.02 2.61-.01 3.91-.02.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z" />,
  youtube: <path fill="currentColor" stroke="none" d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />,
  github: <path fill="currentColor" stroke="none" d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />,
  twitch: <path fill="currentColor" stroke="none" d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z" />,
  discord: <path fill="currentColor" stroke="none" d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />,
  linkedin: <svg x="1" y="1" width="22" height="22" viewBox="0 0 448 512"><path fill="currentColor" stroke="none" d="M416 32L31.9 32C14.3 32 0 46.5 0 64.3L0 447.7C0 465.5 14.3 480 31.9 480L416 480c17.6 0 32-14.5 32-32.3l0-383.4C448 46.5 433.6 32 416 32zM135.4 416l-66.4 0 0-213.8 66.5 0 0 213.8-.1 0zM102.2 96a38.5 38.5 0 1 1 0 77 38.5 38.5 0 1 1 0-77zM384.3 416l-66.4 0 0-104c0-24.8-.5-56.7-34.5-56.7-34.6 0-39.9 27-39.9 54.9l0 105.8-66.4 0 0-213.8 63.7 0 0 29.2 .9 0c8.9-16.8 30.6-34.5 62.9-34.5 67.2 0 79.7 44.3 79.7 101.9l0 117.2z" /></svg>,
  spotify: <path fill="currentColor" stroke="none" d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />,
  bluesky: <path fill="currentColor" stroke="none" d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026" />,
  threads: <path fill="currentColor" stroke="none" d="M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z" />,
  telegram: <path fill="currentColor" stroke="none" d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" />,
  signal: <path fill="currentColor" stroke="none" d="M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125" />,
  whatsapp: <path fill="currentColor" stroke="none" d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />,
  facebook: <path fill="currentColor" stroke="none" d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" />,
  snapchat: <path fill="currentColor" stroke="none" d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" />,
  reddit: <path fill="currentColor" stroke="none" d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" />,
  patreon: <path fill="currentColor" stroke="none" d="M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z" />,
  kofi: <path fill="currentColor" stroke="none" d="M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298" />,
  tumblr: <path fill="currentColor" stroke="none" d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" />,
  soundcloud: <path fill="currentColor" stroke="none" d="M23.999 14.165c-.052 1.796-1.612 3.169-3.4 3.169h-8.18a.68.68 0 0 1-.675-.683V7.862a.747.747 0 0 1 .452-.724s.75-.513 2.333-.513a5.364 5.364 0 0 1 2.763.755 5.433 5.433 0 0 1 2.57 3.54c.282-.08.574-.121.868-.12.884 0 1.73.358 2.347.992s.948 1.49.922 2.373ZM10.721 8.421c.247 2.98.427 5.697 0 8.672a.264.264 0 0 1-.53 0c-.395-2.946-.22-5.718 0-8.672a.264.264 0 0 1 .53 0ZM9.072 9.448c.285 2.659.37 4.986-.006 7.655a.277.277 0 0 1-.55 0c-.331-2.63-.256-5.02 0-7.655a.277.277 0 0 1 .556 0Zm-1.663-.257c.27 2.726.39 5.171 0 7.904a.266.266 0 0 1-.532 0c-.38-2.69-.257-5.21 0-7.904a.266.266 0 0 1 .532 0Zm-1.647.77a26.108 26.108 0 0 1-.008 7.147.272.272 0 0 1-.542 0 27.955 27.955 0 0 1 0-7.147.275.275 0 0 1 .55 0Zm-1.67 1.769c.421 1.865.228 3.5-.029 5.388a.257.257 0 0 1-.514 0c-.21-1.858-.398-3.549 0-5.389a.272.272 0 0 1 .543 0Zm-1.655-.273c.388 1.897.26 3.508-.01 5.412-.026.28-.514.283-.54 0-.244-1.878-.347-3.54-.01-5.412a.283.283 0 0 1 .56 0Zm-1.668.911c.4 1.268.257 2.292-.026 3.572a.257.257 0 0 1-.514 0c-.241-1.262-.354-2.312-.023-3.572a.283.283 0 0 1 .563 0Z" />,
  pinterest: <path fill="currentColor" stroke="none" d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z" />,
  kick: <path fill="currentColor" stroke="none" d="M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z" />,
  rumble: <path fill="currentColor" stroke="none" d="M14.4528 13.5458c.8064-.6542.9297-1.8381.2756-2.6445a1.8802 1.8802 0 0 0-.2756-.2756 21.2127 21.2127 0 0 0-4.3121-2.776c-1.066-.51-2.256.2-2.4261 1.414a23.5226 23.5226 0 0 0-.14 5.5021c.116 1.23 1.292 1.964 2.372 1.492a19.6285 19.6285 0 0 0 4.5062-2.704v-.008zm6.9322-5.4002c2.0335 2.228 2.0396 5.637.014 7.8723A26.1487 26.1487 0 0 1 8.2946 23.846c-2.6848.6713-5.4168-.914-6.1662-3.5781-1.524-5.2002-1.3-11.0803.17-16.3045.772-2.744 3.3521-4.4661 6.0102-3.832 4.9242 1.174 9.5443 4.196 13.0764 8.0121v.002z" />,
  trovo: <><rect x="4" y="4" width="2.6" height="16" fill="currentColor" stroke="none" /><path fill="currentColor" stroke="none" d="M8.2 6.5h6.7l6.9 5.5-6.9 5.5H8.2z" /></>,
  dlive: <path fill="currentColor" stroke="none" fillRule="evenodd" d="M5 4H12.5L16.5 6.2L19 9.3V14.7L16.5 17.8L12.5 20H5V4Z M8.2 7.3H12L14 8.8L15.3 10.6V13.4L14 15.2L12 16.7H8.2V7.3Z" />,
  truthsocial: <><rect x="6.2" y="4.5" width="13.6" height="2.8" fill="currentColor" stroke="none" /><rect x="3.2" y="4.5" width="2.6" height="2.6" fill="currentColor" stroke="none" /><rect x="11.6" y="4.5" width="2.8" height="15" fill="currentColor" stroke="none" /><rect x="17.5" y="17.2" width="2.6" height="2.6" fill="currentColor" stroke="none" /></>,
  pumpfun: <g transform="rotate(45 12 12)"><path fill="currentColor" stroke="none" d="M12 9H7A3 3 0 0 0 4 12A3 3 0 0 0 7 15H12Z" /><path fill="none" d="M12 9H17A3 3 0 0 1 20 12A3 3 0 0 1 17 15H12Z" /></g>,
  farcaster: <path fill="currentColor" stroke="none" d="M18.24.24H5.76C2.5789.24 0 2.8188 0 6v12c0 3.1811 2.5789 5.76 5.76 5.76h12.48c3.1812 0 5.76-2.5789 5.76-5.76V6C24 2.8188 21.4212.24 18.24.24m.8155 17.1662v.504c.2868-.0256.5458.1905.5439.479v.5688h-5.1437v-.5688c-.0019-.2885.2576-.5047.5443-.479v-.504c0-.22.1525-.402.358-.458l-.0095-4.3645c-.1589-1.7366-1.6402-3.0979-3.4435-3.0979-1.8038 0-3.2846 1.3613-3.4435 3.0979l-.0096 4.3578c.2276.0424.5318.2083.5395.4648v.504c.2863-.0256.5457.1905.5438.479v.5688H4.3915v-.5688c-.0019-.2885.2575-.5047.5438-.479v-.504c0-.2529.2011-.4548.4536-.4724v-7.895h-.4905L4.2898 7.008l2.6405-.0005V5.0419h9.9495v1.9656h2.8219l-.6091 2.0314h-.4901v7.8949c.2519.0177.453.2195.453.4724" />,
  opensea: <path fill="currentColor" stroke="none" d="M12 0C5.374 0 0 5.374 0 12s5.374 12 12 12 12-5.374 12-12S18.629 0 12 0ZM5.92 12.403l.051-.081 3.123-4.884a.107.107 0 0 1 .187.014c.52 1.169.972 2.623.76 3.528-.088.372-.335.876-.614 1.342a2.405 2.405 0 0 1-.117.199.106.106 0 0 1-.09.045H6.013a.106.106 0 0 1-.091-.163zm13.914 1.68a.109.109 0 0 1-.065.101c-.243.103-1.07.485-1.414.962-.878 1.222-1.548 2.97-3.048 2.97H9.053a4.019 4.019 0 0 1-4.013-4.028v-.072c0-.058.048-.106.108-.106h3.485c.07 0 .12.063.115.132-.026.226.017.459.125.67.206.42.636.682 1.099.682h1.726v-1.347H9.99a.11.11 0 0 1-.089-.173l.063-.09c.16-.231.391-.586.621-.992.156-.274.308-.566.43-.86.024-.052.043-.107.065-.16.033-.094.067-.182.091-.269a4.57 4.57 0 0 0 .065-.223c.057-.25.081-.514.081-.787 0-.108-.004-.221-.014-.327-.005-.117-.02-.235-.034-.352a3.415 3.415 0 0 0-.048-.312 6.494 6.494 0 0 0-.098-.468l-.014-.06c-.03-.108-.056-.21-.09-.317a11.824 11.824 0 0 0-.328-.972 5.212 5.212 0 0 0-.142-.355c-.072-.178-.146-.339-.213-.49a3.564 3.564 0 0 1-.094-.197 4.658 4.658 0 0 0-.103-.213c-.024-.053-.053-.104-.072-.152l-.211-.388c-.029-.053.019-.118.077-.101l1.32.357h.01l.173.05.192.054.07.019v-.783c0-.379.302-.686.679-.686a.66.66 0 0 1 .477.202.69.69 0 0 1 .2.484V6.65l.141.039c.01.005.022.01.031.017.034.024.084.062.147.11.05.038.103.086.165.137a10.351 10.351 0 0 1 .574.504c.214.199.454.432.684.691.065.074.127.146.192.226.062.079.132.156.19.232.079.104.16.212.235.324.033.053.074.108.105.161.096.142.178.288.257.435.034.067.067.141.096.213.089.197.159.396.202.598a.65.65 0 0 1 .029.132v.01c.014.057.019.12.024.184a2.057 2.057 0 0 1-.106.874c-.031.084-.06.17-.098.254-.075.17-.161.343-.264.502-.034.06-.075.122-.113.182-.043.063-.089.123-.127.18a3.89 3.89 0 0 1-.173.221c-.053.072-.106.144-.166.209-.081.098-.16.19-.245.278-.048.058-.1.118-.156.17-.052.06-.108.113-.156.161-.084.084-.15.147-.208.202l-.137.122a.102.102 0 0 1-.072.03h-1.051v1.346h1.322c.295 0 .576-.104.804-.298.077-.067.415-.36.816-.802a.094.094 0 0 1 .05-.03l3.65-1.057a.108.108 0 0 1 .138.103z" />,
  magiceden: <><path d="M7 4h10l4 5-9 11L4 9z" /><path d="M4 9h16M9.5 4L12 9l-2 11M14.5 4L12 9l2 11" /></>,
  website: <><circle cx="12" cy="12" r="8.5" /><path d="M3.5 12h17M12 3.5c-2.6 2.4-3.9 5.2-3.9 8.5s1.3 6.1 3.9 8.5c2.6-2.4 3.9-5.2 3.9-8.5s-1.3-6.1-3.9-8.5z" /></>,
  email: <><rect x="3" y="5.5" width="18" height="13" rx="3.5" /><path d="M4 7.5l8 6 8-6" /></>,
};
// Coconut characters placed freely on the page.
//
// Two layers rather than one: those sent behind the content, and those brought
// in front. The front layer is pointer-events: none throughout — a character
// dropped over a link must never swallow the tap that link exists for, and
// that is a decoration the owner placed for looks, not a control.
function CocoLayer({ cocos, front }) {
  const items = (Array.isArray(cocos) ? cocos : []).filter((c) => c && (front ? c.f : !c.f));
  if (!items.length) return null;
  return (
    <div className={"coco-layer" + (front ? " is-front" : "")} aria-hidden="true">
      {items.map((c, i) => (
        <img key={i} className="coco" src={"/cocos/" + c.id + ".png"} alt="" loading="lazy"
             style={{
               left: c.x + "%",
               top: c.y + "%",
               transform: "translate(-50%, -50%) rotate(" + (c.r || 0) + "deg) scale(" + (c.s || 1) + ")",
             }} />
      ))}
    </div>
  );
}

function SocialsRow({ socials, overrides }) {
  const items = (Array.isArray(socials) ? socials : []).filter((s) => s && SOCIAL_ICONS[s.type]);
  if (items.length === 0) return null;
  return (
    <div className="socials-row">
      {items.map((s, i) => (
        <a className="social-ico" data-net={s.type} data-social-id={s.type}
           {...(socialOverrideProps(overrides, s.type) || {})} key={s.type + i}
           href={s.url || undefined}
           target="_blank" rel="noopener noreferrer" aria-label={s.type}>
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
               strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            {SOCIAL_ICONS[s.type]}
          </svg>
        </a>
      ))}
    </div>
  );
}

// ── ProjectsSection ──────────────────────────────────────────────────────────
// Builder mode: project sheets with a snap photo gallery, expandable
// description, site link, and an optional account-only comment thread.
function ProjectComments({ projectId, count, interactive, overrides }) {
  const t = useI18n();
  const [open, setOpen] = useState(false);
  const [data, setData] = useState(null);
  const [me, setMe] = useState(null);
  const [text, setText] = useState("");
  const [busy, setBusy] = useState(false);
  const load = () => {
    fetch(`/api/projects/${projectId}/comments`)
      .then((r) => r.ok ? r.json() : null)
      .then((d) => { if (d) setData(d); })
      .catch(() => {});
  };
  const toggle = () => {
    if (!interactive) return;
    const next = !open;
    setOpen(next);
    if (next && !data) {
      load();
      fetch("/api/me", { credentials: "same-origin" })
        .then((r) => r.ok ? r.json() : null)
        .then((j) => setMe(j && j.user ? j.user : null))
        .catch(() => setMe(null));
    }
  };
  const send = () => {
    const body = text.trim();
    if (!body || busy) return;
    setBusy(true);
    fetch(`/api/projects/${projectId}/comments`, {
      method: "POST",
      credentials: "same-origin",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ body }),
    })
      .then((r) => r.ok ? r.json() : null)
      .then((d) => { if (d) { setText(""); load(); } })
      .catch(() => {})
      .finally(() => setBusy(false));
  };
  const remove = (cid) => {
    fetch(`/api/projects/${projectId}/comments/${cid}`, {
      method: "DELETE", credentials: "same-origin",
    }).then(() => load()).catch(() => {});
  };
  return (
    <div className="prj-comments">
      <button type="button" className="prj-comments-btn" onClick={toggle} aria-expanded={open}>
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
             strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M21 11.5a8.5 8.5 0 0 1-12.4 7.6L4 20l1-4.4A8.5 8.5 0 1 1 21 11.5z" />
        </svg>
        {t("prj.comments", null, "Commentaires")}
        {count > 0 && <span className="prj-comments-n">{count}</span>}
      </button>
      {open && (
        <div className="prj-comments-panel">
          {!data ? (
            <div className="prj-empty">{"\u2026"}</div>
          ) : (
            <>
              {data.comments.length === 0 && (
                <div className="prj-empty">{t("prj.first_comment", null, "Sois le premier \u00e0 commenter !")}</div>
              )}
              {data.comments.map((c) => (
                <div className="prj-comment" key={c.id}>
                  <span className="prj-comment-ava" aria-hidden="true">
                    {c.photoUrl ? <img src={c.photoUrl} alt="" loading="lazy" /> : (c.displayName || "?").slice(0, 1).toUpperCase()}
                  </span>
                  <span className="prj-comment-body">
                    <a className="prj-comment-name" href={"/" + c.pseudo}>{c.displayName}</a>
                    <span className="prj-comment-text">{c.body}</span>
                  </span>
                  {me && (me.id === c.userId || me.id === data.ownerId) && (
                    <button type="button" className="prj-comment-del" onClick={() => remove(c.id)}
                            aria-label={t("prj.delete", null, "Supprimer")}>
                      <svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor"
                           strokeWidth="2.2" strokeLinecap="round" aria-hidden="true">
                        <path d="M6 6l12 12M18 6L6 18" />
                      </svg>
                    </button>
                  )}
                </div>
              ))}
              {me ? (
                <div className="prj-comment-form">
                  <input className="prj-comment-input" data-field-id="comment" {...(fieldOverrideProps(overrides, "comment") || {})} type="text" maxLength={500} value={text}
                         onChange={(e) => setText(e.target.value)}
                         onKeyDown={(e) => { if (e.key === "Enter") send(); }}
                         placeholder={t("prj.comment_ph", null, "\u00c9cris un commentaire\u2026")} />
                  <button type="button" className="prj-comment-send" data-button-id="comment" {...(buttonOverrideProps(overrides, "comment") || {})} onClick={send} disabled={!text.trim() || busy}>
                    {t("prj.send", null, "Envoyer")}
                  </button>
                </div>
              ) : (
                <a className="prj-login" href="/login">
                  {t("prj.login", null, "Connecte-toi pour commenter")}
                </a>
              )}
            </>
          )}
        </div>
      )}
    </div>
  );
}

function ProjectCard({ p, interactive, overrides }) {
  const t = useI18n();
  const [expanded, setExpanded] = useState(false);
  const long = (p.description || "").length > 180;
  const desc = expanded || !long ? p.description : p.description.slice(0, 180) + "\u2026";
  return (
    <div className="prj-card" data-card-id="projects" {...(cardOverrideProps(overrides, "projects") || {})}>
      {p.images.length > 0 && (
        <div className="prj-gallery" data-n={p.images.length}>
          {p.images.map((u, i) => (
            <img className="prj-img" src={u} alt={p.title + " " + (i + 1)} key={i} loading="lazy" />
          ))}
        </div>
      )}
      <div className="prj-body">
        <div className="prj-title">{p.title}</div>
        {p.tagline && <div className="prj-tagline">{p.tagline}</div>}
        {p.description && (
          <div className="prj-desc">
            {desc}
            {long && (
              <button type="button" className="prj-more" onClick={() => setExpanded(!expanded)}>
                {expanded ? t("prj.less", null, "Voir moins") : t("prj.more", null, "Voir plus")}
              </button>
            )}
          </div>
        )}
        {p.url && (
          <a className="prj-visit" data-button-id="project" {...(buttonOverrideProps(overrides, "project") || {})} href={interactive ? p.url : undefined}
             target="_blank" rel="noopener noreferrer">
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
                 strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M14 4h6v6M20 4l-9 9M11 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-5" />
            </svg>
            {t("prj.visit", null, "Voir le site")}
          </a>
        )}
        {p.commentsEnabled && (
          <ProjectComments projectId={p.id} count={p.commentCount} interactive={interactive} overrides={overrides} />
        )}
      </div>
    </div>
  );
}

// ── Coin mode (crypto launch sheet) ──────────────────────────────────────────
const CHAIN_LABELS = {
  solana: "Solana", base: "Base", ethereum: "Ethereum", bsc: "BNB Chain",
  polygon: "Polygon", arbitrum: "Arbitrum", tron: "Tron", ton: "TON",
  sui: "Sui", avalanche: "Avalanche", bitcoin: "Bitcoin",
};
// DexScreener chain ids for the live-stats lookup.
const DEX_CHAIN = {
  solana: "solana", base: "base", ethereum: "ethereum", bsc: "bsc",
  polygon: "polygon", arbitrum: "arbitrum", tron: "tron", ton: "ton",
  sui: "sui", avalanche: "avalanche",
};
function fmtUsd(n) {
  if (n == null || isNaN(n)) return null;
  const v = +n;
  if (v >= 1e9) return "$" + (v / 1e9).toFixed(2) + "B";
  if (v >= 1e6) return "$" + (v / 1e6).toFixed(2) + "M";
  if (v >= 1e3) return "$" + (v / 1e3).toFixed(1) + "K";
  return "$" + v.toFixed(0);
}

function CoinHeader({ coin, interactive, overrides }) {
  const t = useI18n();
  const [copied, setCopied] = useState(false);
  const [live, setLive] = useState(null); // {priceUsd, liquidity, mcap, vol24}
  useEffect(() => {
    if (!coin.contract || !coin.chain || !DEX_CHAIN[coin.chain]) return;
    // Live numbers from DexScreener (public, no key). Best-effort.
    fetch("https://api.dexscreener.com/latest/dex/tokens/" + encodeURIComponent(coin.contract))
      .then((r) => r.ok ? r.json() : null)
      .then((d) => {
        const pairs = (d && d.pairs) || [];
        if (!pairs.length) return;
        // Prefer the pair on the declared chain with the deepest liquidity.
        const onChain = pairs.filter((p) => p.chainId === DEX_CHAIN[coin.chain]);
        const pool = (onChain.length ? onChain : pairs)
          .sort((a, b) => (b.liquidity?.usd || 0) - (a.liquidity?.usd || 0))[0];
        if (!pool) return;
        setLive({
          priceUsd: pool.priceUsd ? +pool.priceUsd : null,
          liquidity: pool.liquidity?.usd ?? null,
          mcap: pool.marketCap ?? pool.fdv ?? null,
          vol24: pool.volume?.h24 ?? null,
        });
      })
      .catch(() => {});
  }, [coin.contract, coin.chain]);

  const copy = () => {
    if (!interactive || !coin.contract) return;
    try {
      navigator.clipboard.writeText(coin.contract);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch { /* ignore */ }
  };
  const short = coin.contract
    ? coin.contract.slice(0, 6) + "\u2026" + coin.contract.slice(-4)
    : "";

  return (
    <section className="coin-card" data-card-id="coin" {...(cardOverrideProps(overrides, "coin") || {})} aria-label="Token">
      <div className="coin-head">
        {coin.logoUrl && <img className="coin-logo" src={coin.logoUrl} alt="" loading="lazy" />}
        <div className="coin-head-text">
          <div className="coin-ticker">
            {coin.ticker ? (coin.ticker[0] === "$" ? coin.ticker : "$" + coin.ticker) : t("coin.token", null, "Token")}
            {coin.chain && CHAIN_LABELS[coin.chain] && (
              <span className="coin-chain" data-badge-id="coinchain" {...(badgeOverrideProps(overrides, "coinchain") || {})}>{CHAIN_LABELS[coin.chain]}</span>
            )}
          </div>
          {coin.tagline && <div className="coin-tagline">{coin.tagline}</div>}
        </div>
      </div>

      {coin.contract && (
        <button type="button" className="coin-contract" onClick={copy}>
          <span className="coin-contract-addr">{short}</span>
          <span className="coin-contract-copy">
            {copied ? t("coin.copied", null, "Copi\u00e9 !") : t("coin.copy", null, "Copier")}
          </span>
        </button>
      )}

      <div className="coin-stats">
        <div className="coin-stat">
          <span className="coin-stat-k">{t("coin.price", null, "Prix")}</span>
          <span className="coin-stat-v">{live && live.priceUsd != null ? "$" + (live.priceUsd < 0.01 ? live.priceUsd.toPrecision(2) : live.priceUsd.toFixed(4)) : "\u2014"}</span>
        </div>
        <div className="coin-stat">
          <span className="coin-stat-k">{t("coin.mcap", null, "Market cap")}</span>
          <span className="coin-stat-v">{live && fmtUsd(live.mcap) ? fmtUsd(live.mcap) : "\u2014"}</span>
        </div>
        <div className="coin-stat">
          <span className="coin-stat-k">{t("coin.liq", null, "Liquidit\u00e9")}</span>
          <span className="coin-stat-v">{live && fmtUsd(live.liquidity) ? fmtUsd(live.liquidity) : "\u2014"}</span>
        </div>
        <div className="coin-stat">
          <span className="coin-stat-k">{t("coin.vol", null, "Volume 24h")}</span>
          <span className="coin-stat-v">{live && fmtUsd(live.vol24) ? fmtUsd(live.vol24) : "\u2014"}</span>
        </div>
        {coin.supply && (
          <div className="coin-stat">
            <span className="coin-stat-k">{t("coin.supply", null, "Supply")}</span>
            <span className="coin-stat-v">{coin.supply}</span>
          </div>
        )}
      </div>

      {coin.buyUrl && (
        <a className="coin-buy" data-button-id="coin" {...(buttonOverrideProps(overrides, "coin") || {})} href={interactive ? coin.buyUrl : undefined}
           target="_blank" rel="noopener noreferrer">
          {t("coin.buy", null, "Acheter le token")}
        </a>
      )}
      <div className="coin-disclaimer">
        {t("coin.disclaimer", null, "Ceci n'est pas un conseil financier. Les crypto-actifs sont risqu\u00e9s.")}
      </div>
    </section>
  );
}

const COIN_KIND_LABELS = {
  whitepaper: "Livre blanc", announcement: "Annonce",
  roadmap: "Roadmap", ama: "AMA", update: "Update",
};
function CoinPost({ post, interactive, overrides }) {
  const t = useI18n();
  const [open, setOpen] = useState(false);
  const long = (post.body || "").length > 220;
  const body = open || !long ? post.body : post.body.slice(0, 220) + "\u2026";
  const share = () => {
    if (!interactive) return;
    const url = window.location.origin + "/p/" + post.id;
    if (navigator.share) {
      navigator.share({ title: post.title, url }).catch(() => {});
    } else {
      try { navigator.clipboard.writeText(url); } catch { /* ignore */ }
    }
  };
  return (
    <div className="coin-post">
      <div className="coin-post-top">
        <span className="coin-post-kind" data-badge-id="coinpost" {...(badgeOverrideProps(overrides, "coinpost") || {})}>{t("coin.kind." + post.kind, null, COIN_KIND_LABELS[post.kind] || "Update")}</span>
        <button type="button" className="coin-post-share" data-button-id="coinpost" {...(buttonOverrideProps(overrides, "coinpost") || {})} onClick={share} aria-label={t("coin.share", null, "Partager")}>
          <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
               strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <circle cx="18" cy="5" r="2.6" /><circle cx="6" cy="12" r="2.6" /><circle cx="18" cy="19" r="2.6" />
            <path d="M8.3 10.8l7.4-4.3M8.3 13.2l7.4 4.3" />
          </svg>
        </button>
      </div>
      <a className="coin-post-title" href={interactive ? "/p/" + post.id : undefined}>{post.title}</a>
      {post.body && (
        <div className="coin-post-body">
          {body}
          {long && (
            <button type="button" className="coin-post-more" onClick={() => setOpen(!open)}>
              {open ? t("prj.less", null, "Voir moins") : t("prj.more", null, "Voir plus")}
            </button>
          )}
        </div>
      )}
      {post.linkUrl && (
        <a className="coin-post-link" data-button-id="coinpost" {...(buttonOverrideProps(overrides, "coinpost") || {})} href={interactive ? post.linkUrl : undefined}
           target="_blank" rel="noopener noreferrer">
          <svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor"
               strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M14 4h6v6M20 4l-9 9M11 5H6a2 2 0 0 0-2 2v11a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-5" />
          </svg>
          {t("coin.open_link", null, "Ouvrir le lien")}
        </a>
      )}
    </div>
  );
}

// ── Business mode (local business sheet) ─────────────────────────────────────
const BUSINESS_CAT_LABELS = {
  restaurant: "Restaurant", ranch: "Ranch", bar: "Bar", cafe: "Caf\u00e9",
  bakery: "Boulangerie", farm: "Ferme", shop: "Boutique", services: "Services",
  hotel: "H\u00f4tel", venue: "Lieu", other: "",
};

// The label shown under the business name. "other" has no fixed wording —
// it uses whatever the owner typed (a gîte, a gallery, a climbing gym), so
// picking "Autre" no longer results in a blank category.
function businessCatLabel(business, t) {
  if (!business) return "";
  if (business.category === "other") return (business.categoryOther || "").trim();
  const fallback = business.category && BUSINESS_CAT_LABELS[business.category];
  if (!fallback) return "";
  return t ? t("linktree.biz_cat." + business.category, null, fallback) : fallback;
}

// Where the "Directions" button points, or null when it shouldn't be shown
// at all. An explicit maps link wins over an address search, so a vague
// address (a village name) can still point at the exact spot; and the owner
// can hide the button entirely while keeping the address on display.
function businessDirectionsUrl(business) {
  if (!business || business.showDirections === false) return null;
  const explicit = (business.mapsUrl || "").trim();
  if (explicit) return explicit;
  const addr = (business.address || "").trim();
  return addr ? "https://maps.google.com/?q=" + encodeURIComponent(addr) : null;
}

function BusinessSection({ business, interactive, overrides }) {
  const t = useI18n();
  if (!business) return null;
  const mapsUrl = businessDirectionsUrl(business);
  const telUrl = business.phone ? "tel:" + business.phone.replace(/[^\d+]/g, "") : null;
  const cat = businessCatLabel(business, t);
  // Group items by section (a section is optional; "" = ungrouped first).
  const items = Array.isArray(business.items) ? business.items : [];
  const sections = [];
  const byName = {};
  items.forEach((it) => {
    const key = it.section || "";
    if (!byName[key]) { byName[key] = { name: key, items: [] }; sections.push(byName[key]); }
    byName[key].items.push(it);
  });

  return (
    <section className="biz-card" data-card-id="business" {...(cardOverrideProps(overrides, "business") || {})} aria-label="Business">
      <div className="biz-head">
        <div className="biz-head-text">
          <div className="biz-name">{business.name || ""}</div>
          {(cat || business.tagline) && (
            <div className="biz-sub">
              {cat && <span className="biz-cat" data-badge-id="category" {...(badgeOverrideProps(overrides, "category") || {})}>{cat}</span>}
              {business.tagline && <span className="biz-tagline">{business.tagline}</span>}
            </div>
          )}
        </div>
      </div>

      {business.images && business.images.length > 0 && (
        <div className="biz-gallery" data-n={business.images.length}
             data-fit={business.photoFit === "contain" ? "contain" : "cover"}>
          {business.images.map((u, i) => (
            <div className="biz-shot" key={i}>
              {/* In "whole photo" mode a blurred copy of the picture fills the
                  frame behind it, so nothing is cropped and we still avoid
                  bare letterbox bars — the rounded frame stays uniform. */}
              {business.photoFit === "contain" && (
                <img className="biz-shot-bg" src={u} alt="" aria-hidden="true" loading="lazy" />
              )}
              <img className="biz-img" src={u} alt={(business.name || "") + " " + (i + 1)} loading="lazy" />
            </div>
          ))}
        </div>
      )}

      <div className="biz-actions" data-button-id="business">
        {telUrl && (
          <a className="biz-action" data-button-id="business" {...(buttonOverrideProps(overrides, "business") || {})} href={interactive ? telUrl : undefined}>
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M6.6 10.8a13 13 0 0 0 6.6 6.6l2.2-2.2a1.2 1.2 0 0 1 1.2-.3 9 9 0 0 0 3 .5 1.2 1.2 0 0 1 1.2 1.2v3.3a1.2 1.2 0 0 1-1.2 1.2A17 17 0 0 1 3 4.2 1.2 1.2 0 0 1 4.2 3h3.3a1.2 1.2 0 0 1 1.2 1.2 9 9 0 0 0 .5 3 1.2 1.2 0 0 1-.3 1.2z" />
            </svg>
            {t("biz.call", null, "Appeler")}
          </a>
        )}
        {mapsUrl && (
          <a className="biz-action" data-button-id="business" {...(buttonOverrideProps(overrides, "business") || {})} href={interactive ? mapsUrl : undefined} target="_blank" rel="noopener noreferrer">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" /><circle cx="12" cy="10" r="2.5" />
            </svg>
            {t("biz.directions", null, "Itin\u00e9raire")}
          </a>
        )}
        {business.bookingUrl && (
          <a className="biz-action biz-action-primary" data-button-id="business" {...(buttonOverrideProps(overrides, "business") || {})} href={interactive ? business.bookingUrl : undefined} target="_blank" rel="noopener noreferrer">
            <svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
              <rect x="3.5" y="5" width="17" height="16" rx="3" /><path d="M3.5 9.5h17M8 3v4M16 3v4" />
            </svg>
            {t("biz.book", null, "R\u00e9server")}
          </a>
        )}
      </div>

      {((business.address || "").trim() || (business.hours || "").trim()) && (
        <div className="biz-info">
          {(business.address || "").trim() && (
            <div className="biz-info-row">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <path d="M12 21s7-5.6 7-11a7 7 0 1 0-14 0c0 5.4 7 11 7 11z" /><circle cx="12" cy="10" r="2.5" />
              </svg>
              <span>{business.address}</span>
            </div>
          )}
          {(business.hours || "").trim() && (
            <div className="biz-info-row biz-info-hours">
              <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <circle cx="12" cy="12" r="9" /><path d="M12 7v5l3.5 2" />
              </svg>
              <span>{business.hours}</span>
            </div>
          )}
        </div>
      )}

      {sections.length > 0 && sections.some((s) => s.items.length) && (
        <div className="biz-menu">
          {sections.map((sec, si) => (
            <div className="biz-menu-section" key={si}>
              {sec.name && <div className="biz-menu-title">{sec.name}</div>}
              {sec.items.map((it) => (
                <div className="biz-item" key={it.id}>
                  {it.imageUrl && <img className="biz-item-img" src={it.imageUrl} alt="" loading="lazy" />}
                  <div className="biz-item-body">
                    <div className="biz-item-top">
                      <span className="biz-item-name">{it.name}</span>
                      {it.price && <span className="biz-item-price">{it.price}</span>}
                    </div>
                    {it.description && <div className="biz-item-desc">{it.description}</div>}
                  </div>
                </div>
              ))}
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

function CoinSection({ coin, interactive, overrides }) {
  const t = useI18n();
  if (!coin) return null;
  return (
    <>
      <CoinHeader coin={coin} interactive={interactive}  overrides={overrides} />
      {Array.isArray(coin.posts) && coin.posts.length > 0 && (
        <section className="coin-posts" aria-label="Posts">
          <div className="coin-posts-title">{t("coin.updates", null, "Actualit\u00e9s")}</div>
          {coin.posts.map((p) => <CoinPost post={p} key={p.id} interactive={interactive} overrides={overrides} />)}
        </section>
      )}
    </>
  );
}

// ── Wallet (crypto tip jar) ──────────────────────────────────────────────────
// Non-custodial. The owner lists public receive addresses; visitors can copy,
// scan a QR, or — on EVM chains and Solana — connect their own browser wallet
// and send a tip in one click. Keys/funds never touch the platform: sends are
// signed entirely in the visitor's wallet. Anything that can't be sent in-page
// (Bitcoin, Tron, TON, Sui) gracefully stays copy + QR.

// EVM chains we can switch to and send native currency on, with no library:
// just the wallet's injected provider (window.ethereum). rpc/explorer are only
// used as fallbacks when the chain isn't already added to the visitor's wallet.
const WALLET_EVM = {
  ethereum:  { hex: "0x1",    name: "Ethereum",     symbol: "ETH",  rpc: "https://ethereum-rpc.publicnode.com",          explorer: "https://etherscan.io/tx/",    explorerBase: "https://etherscan.io" },
  base:      { hex: "0x2105", name: "Base",         symbol: "ETH",  rpc: "https://base-rpc.publicnode.com",              explorer: "https://basescan.org/tx/",    explorerBase: "https://basescan.org" },
  arbitrum:  { hex: "0xa4b1", name: "Arbitrum One", symbol: "ETH",  rpc: "https://arbitrum-one-rpc.publicnode.com",      explorer: "https://arbiscan.io/tx/",     explorerBase: "https://arbiscan.io" },
  polygon:   { hex: "0x89",   name: "Polygon",      symbol: "POL",  rpc: "https://polygon-bor-rpc.publicnode.com",       explorer: "https://polygonscan.com/tx/", explorerBase: "https://polygonscan.com" },
  bsc:       { hex: "0x38",   name: "BNB Chain",    symbol: "BNB",  rpc: "https://bsc-rpc.publicnode.com",               explorer: "https://bscscan.com/tx/",     explorerBase: "https://bscscan.com" },
  avalanche: { hex: "0xa86a", name: "Avalanche",    symbol: "AVAX", rpc: "https://avalanche-c-chain-rpc.publicnode.com", explorer: "https://snowtrace.io/tx/",    explorerBase: "https://snowtrace.io" },
};

// What kind of in-page send (if any) a chain supports, plus the symbol shown
// on the amount field and the Send button.
function walletSendMeta(chain) {
  if (WALLET_EVM[chain]) return { kind: "evm", symbol: WALLET_EVM[chain].symbol };
  if (chain === "solana") return { kind: "solana", symbol: "SOL" };
  return { kind: null, symbol: "" };
}

// Decimal amount -> hex wei (18 decimals), via BigInt so we never lose
// precision to floating point. Returns null for anything that isn't a
// positive number.
function toHexWei(amount) {
  const s = String(amount).trim();
  if (s === "" || s === "." || !/^\d*\.?\d*$/.test(s)) return null;
  const parts = s.split(".");
  const whole = parts[0] || "0";
  const fracPad = ((parts[1] || "") + "000000000000000000").slice(0, 18);
  const wei = BigInt(whole) * (BigInt(10) ** BigInt(18)) + BigInt(fracPad || "0");
  if (wei <= BigInt(0)) return null;
  return "0x" + wei.toString(16);
}
// Decimal amount -> integer lamports (9 decimals) for Solana.
function toLamports(amount) {
  const s = String(amount).trim();
  if (s === "" || s === "." || !/^\d*\.?\d*$/.test(s)) return null;
  const parts = s.split(".");
  const whole = parts[0] || "0";
  const fracPad = ((parts[1] || "") + "000000000").slice(0, 9);
  const lamports = BigInt(whole) * BigInt(1000000000) + BigInt(fracPad || "0");
  if (lamports <= BigInt(0)) return null;
  return Number(lamports);
}
// Map a raw wallet/provider error to one of our status codes. Handles both the
// numeric EIP-1193 rejection code and message-based detection (rejection and
// insufficient balance), which is how most Solana wallets report failures.
function mapWalletErr(e) {
  if (!e) return { code: "error" };
  if (e.code === 4001) return { code: "rejected" };
  const known = ["invalid_amount", "no_wallet_evm", "no_wallet_solana", "rejected", "wrong_chain", "insufficient", "error"];
  if (typeof e.code === "string" && known.indexOf(e.code) !== -1) return e;
  const msg = String((e && (e.message || e.reason)) || "").toLowerCase();
  if (msg.indexOf("reject") !== -1 || msg.indexOf("denied") !== -1 || msg.indexOf("cancel") !== -1) return { code: "rejected" };
  if (msg.indexOf("insufficient") !== -1 || msg.indexOf("not enough") !== -1) return { code: "insufficient" };
  return { code: "error" };
}

// Solana's web3 library is only needed when someone actually sends on Solana,
// so we load it on demand (once) — with a backup CDN — rather than shipping it
// to every visitor. Mirrors how React/Babel already load from a CDN here.
const SOLANA_WEB3_CDNS = [
  "https://unpkg.com/@solana/web3.js@1.95.8/lib/index.iife.min.js",
  "https://cdn.jsdelivr.net/npm/@solana/web3.js@1.95.8/lib/index.iife.min.js",
];
let _solanaWeb3Promise = null;
function loadSolanaWeb3() {
  if (typeof window !== "undefined" && window.solanaWeb3) return Promise.resolve(window.solanaWeb3);
  if (_solanaWeb3Promise) return _solanaWeb3Promise;
  _solanaWeb3Promise = new Promise((resolve, reject) => {
    let i = 0;
    const tryNext = () => {
      if (typeof window !== "undefined" && window.solanaWeb3) return resolve(window.solanaWeb3);
      if (i >= SOLANA_WEB3_CDNS.length) return reject(new Error("solana_load"));
      const s = document.createElement("script");
      s.src = SOLANA_WEB3_CDNS[i++];
      s.async = true;
      s.onload = () => (window.solanaWeb3 ? resolve(window.solanaWeb3) : tryNext());
      s.onerror = () => tryNext();
      document.head.appendChild(s);
    };
    tryNext();
  });
  return _solanaWeb3Promise;
}

// Connect the injected EVM wallet, make sure it's on the right chain (adding it
// if needed), and send a native-currency tip. Returns { hash, url }.
async function sendEvmTip(chain, to, amount, provider) {
  const meta = WALLET_EVM[chain];
  if (!meta) throw { code: "error" };
  const eth = provider || ((typeof window !== "undefined") ? window.ethereum : null);
  if (!eth) throw { code: "no_wallet_evm" };
  const value = toHexWei(amount);
  if (!value) throw { code: "invalid_amount" };

  let accounts;
  try { accounts = await eth.request({ method: "eth_requestAccounts" }); }
  catch (e) { throw mapWalletErr(e); }
  const from = accounts && accounts[0];
  if (!from) throw { code: "no_wallet_evm" };

  try {
    await eth.request({ method: "wallet_switchEthereumChain", params: [{ chainId: meta.hex }] });
  } catch (e) {
    const notAdded = e && (e.code === 4902 ||
      (e.data && e.data.originalError && e.data.originalError.code === 4902));
    if (notAdded) {
      try {
        await eth.request({ method: "wallet_addEthereumChain", params: [{
          chainId: meta.hex,
          chainName: meta.name,
          nativeCurrency: { name: meta.symbol, symbol: meta.symbol, decimals: 18 },
          rpcUrls: [meta.rpc],
          blockExplorerUrls: [meta.explorerBase],
        }] });
      } catch (e2) { throw (e2 && e2.code === 4001) ? { code: "rejected" } : { code: "wrong_chain" }; }
    } else if (e && e.code === 4001) {
      throw { code: "rejected" };
    } else {
      throw { code: "wrong_chain" };
    }
  }

  let hash;
  try {
    hash = await eth.request({ method: "eth_sendTransaction", params: [{ from, to, value }] });
  } catch (e) { throw mapWalletErr(e); }
  return { hash, url: meta.explorer + hash };
}

// Public, CORS-friendly Solana RPC endpoints, tried in order. We only use them
// to fetch a recent blockhash (and, if a wallet can't broadcast itself, to send
// the signed transaction). The wallet normally broadcasts on its own.
const SOLANA_RPCS = [
  "https://solana-rpc.publicnode.com",
  "https://solana.drpc.org",
  "https://api.mainnet-beta.solana.com",
];
// Race through the RPC list until one returns a fresh blockhash; hand back the
// Connection that worked so we can reuse it to broadcast if needed.
async function solanaBlockhash(web3) {
  for (let i = 0; i < SOLANA_RPCS.length; i++) {
    try {
      const conn = new web3.Connection(SOLANA_RPCS[i], "confirmed");
      const r = await conn.getLatestBlockhash();
      if (r && r.blockhash) return { conn, blockhash: r.blockhash };
    } catch { /* try the next endpoint */ }
  }
  throw { code: "error" };
}
// Find an injected Solana wallet — Phantom first, then any compatible provider.
function getSolanaProvider() {
  if (typeof window === "undefined") return null;
  if (window.phantom && window.phantom.solana && window.phantom.solana.isPhantom) return window.phantom.solana;
  if (window.solana) return window.solana;
  if (window.solflare && window.solflare.isSolflare) return window.solflare;
  if (window.backpack) return window.backpack;
  return null;
}

// Multiple-wallet support. When several wallets are installed we let the visitor
// pick which one to use, instead of silently using whichever hijacked the page.
// EVM wallets are discovered via EIP-6963 (each announces itself); Solana wallets
// via their known injection points. All de-duplicated by the provider object.
const _evmAnnounced = [];
if (typeof window !== "undefined" && window.addEventListener) {
  window.addEventListener("eip6963:announceProvider", (e) => {
    const d = e && e.detail;
    if (d && d.provider && d.info && !_evmAnnounced.some((x) => x.provider === d.provider)) {
      _evmAnnounced.push({ name: d.info.name || "Wallet", provider: d.provider });
    }
  });
  try { window.dispatchEvent(new Event("eip6963:requestProvider")); } catch (e) { /* ignore */ }
}
function evmName(p) {
  if (!p) return "Wallet";
  if (p.isMetaMask) return "MetaMask";
  if (p.isRabby) return "Rabby";
  if (p.isCoinbaseWallet) return "Coinbase Wallet";
  if (p.isTrust || p.isTrustWallet) return "Trust Wallet";
  if (p.isBraveWallet) return "Brave";
  if (p.isFrame) return "Frame";
  return "Wallet";
}
function getEvmProviders() {
  const list = _evmAnnounced.slice();
  if (typeof window !== "undefined" && window.ethereum) {
    const eth = window.ethereum;
    const multi = Array.isArray(eth.providers) ? eth.providers : [eth];
    multi.forEach((p) => {
      if (p && !list.some((x) => x.provider === p)) list.push({ name: evmName(p), provider: p });
    });
  }
  return list;
}
function getSolanaProviders() {
  const list = [];
  const add = (name, p) => { if (p && !list.some((x) => x.provider === p)) list.push({ name, provider: p }); };
  if (typeof window === "undefined") return list;
  if (window.phantom && window.phantom.solana) add("Phantom", window.phantom.solana);
  if (window.solflare && window.solflare.isSolflare) add("Solflare", window.solflare);
  if (window.backpack) add("Backpack", window.backpack);
  if (window.coinbaseSolana) add("Coinbase Wallet", window.coinbaseSolana);
  if (window.solana) add(window.solana.isPhantom ? "Phantom" : (window.solana.isSolflare ? "Solflare" : "Solana"), window.solana);
  return list;
}
function providersForKind(kind) {
  return kind === "solana" ? getSolanaProviders() : getEvmProviders();
}

// Connect a Solana wallet and send a SOL tip. Robust by design: the web3 lib
// loads from a CDN (with a backup), the blockhash is fetched across several
// RPCs, and broadcasting falls back from signAndSendTransaction to
// signTransaction + manual send. Any failure surfaces as a clear status so the
// visitor can fall back to copy / QR. Returns { hash, url }.
async function sendSolanaTip(to, amount, provider) {
  const prov = provider || getSolanaProvider();
  if (!prov) throw { code: "no_wallet_solana" };
  const lamports = toLamports(amount);
  if (!lamports) throw { code: "invalid_amount" };

  let web3;
  try { web3 = await loadSolanaWeb3(); } catch { throw { code: "error" }; }

  let resp;
  try { resp = await prov.connect(); } catch (e) { throw mapWalletErr(e); }
  const fromPub = (resp && resp.publicKey) ? resp.publicKey : prov.publicKey;
  if (!fromPub) throw { code: "no_wallet_solana" };

  let toPub;
  try { toPub = new web3.PublicKey(to); } catch { throw { code: "error" }; }

  const bh = await solanaBlockhash(web3);

  const tx = new web3.Transaction({ recentBlockhash: bh.blockhash, feePayer: fromPub })
    .add(web3.SystemProgram.transfer({ fromPubkey: fromPub, toPubkey: toPub, lamports }));

  let sig;
  try {
    if (typeof prov.signAndSendTransaction === "function") {
      const r = await prov.signAndSendTransaction(tx);
      sig = r && (r.signature || r);
    } else if (typeof prov.signTransaction === "function") {
      const signed = await prov.signTransaction(tx);
      sig = await bh.conn.sendRawTransaction(signed.serialize());
    } else {
      throw { code: "error" };
    }
  } catch (e) { throw mapWalletErr(e); }
  if (!sig) throw { code: "error" };
  return { hash: sig, url: "https://solscan.io/tx/" + sig };
}

// On mobile, Phantom/MetaMask don't inject a provider unless the page is opened
// inside the wallet app's own browser — so window.solana / window.ethereum are
// absent in normal mobile Safari/Chrome even when the wallet IS installed. In
// that case we hand off to the wallet via a payment link (Solana Pay / EIP-681)
// with the amount pre-filled, instead of telling the user nothing was found.
function isMobileUA() {
  return typeof navigator !== "undefined" && /Android|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || "");
}
function mobileNoInjected(kind) {
  if (!isMobileUA()) return false;
  if (kind === "solana") return !getSolanaProvider();
  if (kind === "evm") return !(typeof window !== "undefined" && window.ethereum);
  return false;
}
function toDecimalWei(amount) {
  const s = String(amount).trim().replace(",", ".");
  if (s === "" || s === "." || !/^\d*\.?\d*$/.test(s)) return null;
  const parts = s.split(".");
  const whole = parts[0] || "0";
  const fracPad = ((parts[1] || "") + "000000000000000000").slice(0, 18);
  return (BigInt(whole) * (BigInt(10) ** BigInt(18)) + BigInt(fracPad || "0")).toString(10);
}
function walletDeeplink(kind, chain, address, amt) {
  if (kind === "solana") {
    return "solana:" + address + "?amount=" + encodeURIComponent(amt);
  }
  const m = WALLET_EVM[chain];
  const chainIdDec = m ? String(parseInt(m.hex, 16)) : "1";
  const wei = toDecimalWei(amt);
  return "ethereum:" + address + "@" + chainIdDec + (wei ? "?value=" + wei : "");
}

// On mobile, wallet apps don't inject into Safari/Chrome, so we can't enumerate
// them from their providers. Instead we let the visitor pick which app to open:
// each link opens that wallet's in-app browser at THIS page, with the tip
// pre-filled (?tip=<id>&amt=<x>) so the send form reopens ready to confirm —
// where the wallet's provider IS injected and the normal send runs.
const WALLET_APPS = {
  solana: [
    { name: "Phantom",  link: (u) => "https://phantom.app/ul/browse/" + encodeURIComponent(u) + "?ref=" + encodeURIComponent(u) },
    { name: "Solflare", link: (u) => "https://solflare.com/ul/v1/browse/" + encodeURIComponent(u) + "?ref=" + encodeURIComponent(u) },
  ],
  evm: [
    { name: "MetaMask",        link: (u) => "https://metamask.app.link/dapp/" + u.replace(/^https?:\/\//, "") },
    { name: "Trust Wallet",    link: (u) => "https://link.trustwallet.com/open_url?coin_id=60&url=" + encodeURIComponent(u) },
    { name: "Coinbase Wallet", link: (u) => "https://go.cb-w.com/dapp?cb_url=" + encodeURIComponent(u) },
  ],
};
function tipReturnUrl(addressId, amt) {
  const base = (typeof location !== "undefined") ? (location.origin + location.pathname) : "";
  return base + "?tip=" + encodeURIComponent(addressId) + "&amt=" + encodeURIComponent(amt);
}

// Live fiat conversion for the amount field. Prices come from CoinGecko (public,
// no key — the same source as the discover ticker), fetched once per page and
// shared across every address. If it fails, the conversion line simply hides.
const COINGECKO_IDS = {
  bitcoin: "bitcoin", solana: "solana",
  ethereum: "ethereum", base: "ethereum", arbitrum: "ethereum",
  polygon: "matic-network", bsc: "binancecoin", avalanche: "avalanche-2",
};
let _walletPrices = null;
let _walletPricesAt = 0;
let _walletPricesPromise = null;
function loadWalletPrices() {
  // Cached for 60s so prices stay current without hammering the API.
  if (_walletPrices && (Date.now() - _walletPricesAt) < 60000) return Promise.resolve(_walletPrices);
  if (_walletPricesPromise) return _walletPricesPromise;
  const ids = "bitcoin,solana,ethereum,matic-network,binancecoin,avalanche-2";
  _walletPricesPromise = fetch("https://api.coingecko.com/api/v3/simple/price?ids=" + ids + "&vs_currencies=usd,eur")
    .then((r) => (r.ok ? r.json() : null))
    .then((d) => {
      if (d) { _walletPrices = d; _walletPricesAt = Date.now(); }
      _walletPricesPromise = null;
      return _walletPrices || {};
    })
    .catch(() => { _walletPricesPromise = null; return _walletPrices || {}; });
  return _walletPricesPromise;
}
function fmtFiat(n) {
  if (n == null || !isFinite(n)) return null;
  const digits = n >= 1000 ? 0 : 2;
  try { return n.toLocaleString(undefined, { minimumFractionDigits: digits, maximumFractionDigits: digits }); }
  catch { return n.toFixed(digits); }
}

// One receive address: chain badge, copyable address, optional QR, and the
// connect-and-send form on supported chains. All actions are inert in the
// editor preview (interactive === false).
function WalletAddress({ a, connectEnabled, interactive, overrides }) {
  const t = useI18n();
  const [copied, setCopied] = useState(false);
  const [showQr, setShowQr] = useState(false);
  const [showSend, setShowSend] = useState(false);
  const [amount, setAmount] = useState("");
  const [status, setStatus] = useState("idle"); // idle | connecting | sending | sent | <error code>
  const [txUrl, setTxUrl] = useState(null);
  const [prices, setPrices] = useState(null);
  const [pickerProviders, setPickerProviders] = useState(null);
  const [mobileApps, setMobileApps] = useState(null);

  const meta = walletSendMeta(a.chain);
  // Show connect-and-send whenever the chain supports it, so the editor preview
  // matches the live page. The actual send only runs where interactive is true
  // (the real page) — that guard lives inside doSend.
  const sendable = connectEnabled && !!meta.kind;
  const busy = status === "connecting" || status === "sending";
  const errCodes = ["invalid_amount", "no_wallet_evm", "no_wallet_solana", "rejected", "wrong_chain", "insufficient", "error"];
  const isError = errCodes.indexOf(status) !== -1;

  // Pull live prices once a sendable address is shown, for the €/$ conversion.
  useEffect(() => {
    if (!sendable) return;
    let alive = true;
    const tick = () => loadWalletPrices().then((p) => { if (alive) setPrices(p); });
    tick();
    const id = setInterval(tick, 60000);
    return () => { alive = false; clearInterval(id); };
  }, [sendable]);
  // Reopen this address's send form pre-filled when the page is loaded inside a
  // wallet app via the mobile chooser (?tip=<id>&amt=<x>).
  useEffect(() => {
    if (typeof location === "undefined" || !interactive || !sendable) return;
    try {
      const p = new URLSearchParams(location.search);
      if (p.get("tip") === String(a.id) && p.get("amt")) {
        setShowSend(true);
        setAmount(p.get("amt"));
      }
    } catch (e) { /* ignore */ }
  }, []);
  const cgId = COINGECKO_IDS[a.chain];
  const priceRow = (prices && cgId && prices[cgId]) ? prices[cgId] : null;
  const amtNum = parseFloat((amount || "").replace(",", "."));
  const fiat = (priceRow && amtNum > 0)
    ? { eur: amtNum * priceRow.eur, usd: amtNum * priceRow.usd }
    : null;

  const copy = () => {
    if (!interactive) return;
    try {
      navigator.clipboard.writeText(a.address);
      setCopied(true);
      setTimeout(() => setCopied(false), 1500);
    } catch { /* ignore */ }
  };
  const short = a.address.length > 18
    ? a.address.slice(0, 8) + "\u2026" + a.address.slice(-6)
    : a.address;

  const attemptSend = async (provider) => {
    setPickerProviders(null);
    const amt = amount.trim().replace(",", ".");
    if (!amt || !(parseFloat(amt) > 0)) { setStatus("invalid_amount"); return; }
    setStatus("sending");
    try {
      const res = meta.kind === "solana"
        ? await sendSolanaTip(a.address, amt, provider)
        : await sendEvmTip(a.chain, a.address, amt, provider);
      setTxUrl(res && res.url ? res.url : null);
      setStatus("sent");
    } catch (e) {
      setStatus((e && e.code) ? e.code : "error");
    }
  };

  const doSend = async () => {
    if (!interactive || busy) return;
    setTxUrl(null);
    setPickerProviders(null);
    setMobileApps(null);
    const amt = amount.trim().replace(",", ".");
    if (!amt || !(parseFloat(amt) > 0)) { setStatus("invalid_amount"); return; }

    // Mobile without an injected wallet: offer to open the visitor's chosen
    // wallet app at this page (tip pre-filled). If we have no app list for this
    // kind, fall back to the OS-level payment link (Solana Pay / EIP-681).
    if (mobileNoInjected(meta.kind)) {
      const apps = WALLET_APPS[meta.kind] || [];
      if (apps.length) { setStatus("idle"); setMobileApps(apps); return; }
      setStatus("opening");
      try { window.location.href = walletDeeplink(meta.kind, a.chain, a.address, amt); }
      catch { setStatus("error"); }
      return;
    }

    const providers = providersForKind(meta.kind);
    if (providers.length === 0) {
      setStatus(meta.kind === "solana" ? "no_wallet_solana" : "no_wallet_evm");
      return;
    }
    if (providers.length === 1) { attemptSend(providers[0].provider); return; }
    // Several wallets installed: let the visitor choose which one to use.
    setStatus("idle");
    setPickerProviders(providers);
  };

  const statusMsg = () => {
    switch (status) {
      case "connecting": return t("wallet.connecting", null, "Connexion\u2026");
      case "sending":    return t("wallet.sending", null, "Envoi en cours\u2026");
      case "opening":    return t("wallet.opening", null, "Ouverture de ton wallet\u2026");
      case "sent":       return t("wallet.sent", null, "Pourboire envoy\u00e9. Merci !");
      case "invalid_amount":   return t("wallet.invalid_amount", null, "Saisis un montant valide.");
      case "no_wallet_evm":    return t("wallet.install_evm", null, "Aucun portefeuille d\u00e9tect\u00e9. Installe MetaMask, Coinbase Wallet ou Trust Wallet.");
      case "no_wallet_solana": return t("wallet.install_solana", null, "Aucun wallet Solana d\u00e9tect\u00e9. Installe Phantom, Coinbase Wallet ou Trust Wallet.");
      case "rejected":   return t("wallet.rejected", null, "Demande annul\u00e9e.");
      case "wrong_chain":return t("wallet.wrong_chain", null, "Impossible de changer de r\u00e9seau dans le wallet.");
      case "insufficient": return t("wallet.insufficient", null, "Solde insuffisant dans le wallet pour ce montant.");
      case "error":      return t("wallet.error", null, "Une erreur s'est produite. Utilise le QR ou copie l'adresse.");
      default:           return "";
    }
  };

  return (
    <div className="wallet-addr">
      <div className="wallet-addr-top">
        <div className="wallet-addr-meta">
          <span className="wallet-chip" data-badge-id="wallet" {...(badgeOverrideProps(overrides, "wallet") || {})}>{CHAIN_LABELS[a.chain] || a.chain}</span>
          {a.label && <span className="wallet-addr-label">{a.label}</span>}
        </div>
        <button type="button" className="wallet-copy" data-button-id="wallet" {...(buttonOverrideProps(overrides, "wallet") || {})} onClick={copy} title={a.address}>
          <span className="wallet-copy-addr" data-button-id="wallet">{short}</span>
          <span className="wallet-copy-tag" data-button-id="wallet">
            {copied ? t("coin.copied", null, "Copi\u00e9 !") : t("coin.copy", null, "Copier")}
          </span>
        </button>
      </div>

      {(sendable || a.qrSvg) && (
        <div className="wallet-actions">
          {sendable && (
            <button type="button" className="wallet-send-btn"
                    onClick={() => { setShowSend((v) => !v); setShowQr(false); }}>
              {t("wallet.send", null, "Connecter & envoyer")}
            </button>
          )}
          {a.qrSvg && (
            <button type="button" className="wallet-qr-btn"
                    onClick={() => { setShowQr((v) => !v); setShowSend(false); }}>
              {showQr ? t("wallet.hide_qr", null, "Masquer le QR") : t("wallet.qr", null, "QR code")}
            </button>
          )}
        </div>
      )}

      {showQr && a.qrSvg && (
        <div className="wallet-qr-wrap">
          <div className="wallet-qr" dangerouslySetInnerHTML={{ __html: a.qrSvg }} />
          <div className="wallet-qr-hint">{t("wallet.scan", null, "Scanne pour envoyer vers cette adresse")}</div>
        </div>
      )}

      {showSend && sendable && (
        <div className="wallet-send">
          <div className="wallet-send-row">
            <input className="wallet-amount" data-field-id="wallet" {...(fieldOverrideProps(overrides, "wallet") || {})} type="text" inputMode="decimal"
                   value={amount} onChange={(e) => setAmount(e.target.value)}
                   placeholder={t("wallet.amount", null, "Montant") + " (" + meta.symbol + ")"}
                   disabled={busy} />
            <button type="button" className="wallet-send-go" data-button-id="wallet" {...(buttonOverrideProps(overrides, "wallet") || {})} onClick={doSend} disabled={busy}>
              {busy
                ? t("wallet.sending", null, "Envoi en cours\u2026")
                : (t("wallet.send_cta", null, "Envoyer") + " " + meta.symbol)}
            </button>
          </div>
          {fiat && (
            <div className="wallet-convert">
              {"\u2248 " + fmtFiat(fiat.eur) + " \u20ac \u00b7 " + fmtFiat(fiat.usd) + " $"}
            </div>
          )}
          {mobileApps && (
            <div className="wallet-picker">
              <div className="wallet-picker-title">{t("wallet.open_in_app", null, "Ouvre dans ton wallet :")}</div>
              <div className="wallet-picker-list">
                {mobileApps.map((app, i) => (
                  <button type="button" key={app.name + i} className="wallet-picker-btn" data-button-id="wallet" {...(buttonOverrideProps(overrides, "wallet") || {})}
                          onClick={() => {
                            const amt = amount.trim().replace(",", ".");
                            setMobileApps(null);
                            setStatus("opening");
                            try { window.location.href = app.link(tipReturnUrl(a.id, amt)); }
                            catch (e) { setStatus("error"); }
                          }}>
                    {app.name}
                  </button>
                ))}
              </div>
            </div>
          )}
          {pickerProviders && (
            <div className="wallet-picker">
              <div className="wallet-picker-title">{t("wallet.choose_wallet", null, "Choisis ton wallet :")}</div>
              <div className="wallet-picker-list">
                {pickerProviders.map((p, i) => (
                  <button type="button" key={p.name + i} className="wallet-picker-btn" data-button-id="wallet" {...(buttonOverrideProps(overrides, "wallet") || {})} onClick={() => attemptSend(p.provider)}>
                    {p.name}
                  </button>
                ))}
              </div>
            </div>
          )}
          {status !== "idle" && (
            <div className={"wallet-status" + (isError ? " is-err" : (status === "sent" ? " is-ok" : ""))}>
              <span>{statusMsg()}</span>
              {status === "sent" && txUrl && (
                <a className="wallet-txlink" href={txUrl} target="_blank" rel="noopener noreferrer">
                  {t("wallet.view_tx", null, "Voir la transaction")}
                </a>
              )}
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ── Engage: polls + questions box ────────────────────────────────────────────
// Visitor-facing audience interaction. Polls let visitors vote on the owner's
// questions (one vote each, deduped server-side by the visit cookie); the
// question box lets them ask the owner something; answered questions the owner
// chose to publish are shown below. All actions are inert in the editor preview.
function PollCard({ poll, pseudo, interactive, voted, overrides }) {
  const t = useI18n();
  const [busy, setBusy] = useState(false);
  const [opts, setOpts] = useState(poll.options || []);
  const [total, setTotal] = useState(poll.total || 0);
  const [chosen, setChosen] = useState(voted != null ? voted : null);

  useEffect(() => { if (voted != null) setChosen(voted); }, [voted]);

  const cast = async (optionId) => {
    if (!interactive || busy || chosen != null) return;
    setBusy(true);
    try {
      const r = await fetch("/api/u/" + encodeURIComponent(pseudo) + "/poll/" + poll.id + "/vote", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ optionId }),
      });
      if (r.ok) {
        const d = await r.json();
        if (d && d.poll) { setOpts(d.poll.options); setTotal(d.poll.total); setChosen(d.poll.voted); }
      }
    } catch (e) { /* ignore */ }
    setBusy(false);
  };

  const showResults = chosen != null;
  return (
    <div className="poll-card" data-card-id="polls" {...(cardOverrideProps(overrides, "polls") || {})}>
      <div className="poll-q">{poll.question}</div>
      <div className="poll-options">
        {opts.map((o) => {
          if (showResults) {
            const pct = total > 0 ? Math.round((o.votes / total) * 100) : 0;
            return (
              <div className={"poll-result" + (chosen === o.id ? " is-mine" : "")} data-field-id="poll" {...(fieldOverrideProps(overrides, "poll") || {})} key={o.id}>
                <div className="poll-result-fill" style={{ width: pct + "%" }} />
                <span className="poll-result-label">{o.label}</span>
                <span className="poll-result-pct">{pct + "%"}</span>
              </div>
            );
          }
          return (
            <button type="button" className="poll-opt" data-field-id="poll" {...(fieldOverrideProps(overrides, "poll") || {})} key={o.id} disabled={busy}
                    onClick={() => cast(o.id)}>
              {o.label}
            </button>
          );
        })}
      </div>
      {showResults && (
        <div className="poll-total">
          {total + " " + (total === 1 ? t("engage.vote_one", null, "vote") : t("engage.vote_many", null, "votes"))}
        </div>
      )}
    </div>
  );
}

function QuestionBox({ pseudo, interactive, intro, overrides }) {
  const t = useI18n();
  const [body, setBody] = useState("");
  const [asker, setAsker] = useState("");
  const [state, setState] = useState("idle"); // idle | sending | sent | error

  const send = async () => {
    if (!interactive || state === "sending") return;
    const b = body.trim();
    if (b.length < 3) { setState("error"); return; }
    setState("sending");
    try {
      const r = await fetch("/api/u/" + encodeURIComponent(pseudo) + "/question", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ body: b, asker: asker.trim() || null }),
      });
      if (r.ok) { setState("sent"); setBody(""); setAsker(""); }
      else setState("error");
    } catch (e) { setState("error"); }
  };

  return (
    <div className="engage-block">
      <div className="engage-title">{t("engage.ask_title", null, "Pose-moi une question")}</div>
      {intro && <div className="engage-intro">{intro}</div>}
      {state === "sent" ? (
        <div className="qbox-sent">{t("engage.ask_sent", null, "Question envoy\u00e9e. Merci !")}</div>
      ) : (
        <div className="qbox">
          <textarea className="qbox-body" data-field-id="question" {...(fieldOverrideProps(overrides, "question") || {})} rows="3" maxLength="600"
                    placeholder={t("engage.ask_ph", null, "Ta question\u2026")}
                    value={body}
                    onChange={(e) => { setBody(e.target.value); if (state === "error") setState("idle"); }} />
          <input className="qbox-name" data-field-id="question" {...(fieldOverrideProps(overrides, "question") || {})} type="text" maxLength="40"
                 placeholder={t("engage.ask_name", null, "Ton nom (optionnel)")}
                 value={asker}
                 onChange={(e) => setAsker(e.target.value)} />
          <button type="button" className="qbox-send" data-button-id="question" {...(buttonOverrideProps(overrides, "question") || {})} disabled={state === "sending"} onClick={send}>
            {state === "sending" ? t("engage.ask_sending", null, "Envoi\u2026") : t("engage.ask_send", null, "Envoyer")}
          </button>
          {state === "error" && (
            <div className="qbox-err">{t("engage.ask_err", null, "Question trop courte ou erreur. R\u00e9essaie.")}</div>
          )}
        </div>
      )}
    </div>
  );
}

function EngageSection({ engage, pseudo, interactive, overrides }) {
  const t = useI18n();
  const [votes, setVotes] = useState({});

  useEffect(() => {
    if (!interactive || !engage) return;
    let alive = true;
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/engage-votes")
      .then((r) => (r.ok ? r.json() : null))
      .then((d) => { if (alive && d && d.votes) setVotes(d.votes); })
      .catch(() => {});
    return () => { alive = false; };
  }, [interactive, pseudo, engage]);

  if (!engage) return null;
  const polls = Array.isArray(engage.polls) ? engage.polls : [];
  const answered = Array.isArray(engage.answered) ? engage.answered : [];
  const hasPolls = polls.length > 0;
  const hasQbox = !!engage.questionsEnabled;
  const hasAnswered = answered.length > 0;
  if (!hasPolls && !hasQbox && !hasAnswered) return null;

  return (
    <section className="engage-card" data-card-id="engage" {...(cardOverrideProps(overrides, "engage") || {})} aria-label="Engage">
      {hasPolls && (
        <div className="engage-block">
          <div className="engage-title">{t("engage.polls_title", null, "Sondages")}</div>
          {polls.map((p) => (
            <PollCard key={p.id} poll={p} pseudo={pseudo} interactive={interactive} voted={votes[p.id]}  overrides={overrides} />
          ))}
        </div>
      )}
      {hasQbox && (
        <QuestionBox pseudo={pseudo} interactive={interactive} intro={engage.questionsIntro} overrides={overrides} />
      )}
      {hasAnswered && (
        <div className="engage-block">
          <div className="engage-title">{t("engage.answers_title", null, "Questions / r\u00e9ponses")}</div>
          {answered.map((q) => (
            <div className="qa-item" data-card-id="qa" {...(cardOverrideProps(overrides, "qa") || {})} key={q.id}>
              <div className="qa-q">{q.body}</div>
              {q.asker && <div className="qa-asker">{q.asker}</div>}
              <div className="qa-a">{q.answer}</div>
            </div>
          ))}
        </div>
      )}
    </section>
  );
}

// ── EmailCaptureCard ─────────────────────────────────────────────────────────
// A small glass card where visitors leave their email. The owner reads and
// copies the collected addresses from the editor. Renders only when the
// owner has turned the card on (data.subscribe.enabled). Idempotent + rate
// limited server-side; the same address twice is a silent success. Follows
// the page's glass language and the owner's locale like every other block.
function EmailCaptureCard({ sub, pseudo, interactive, overrides }) {
  const t = useI18n();
  const [email, setEmail] = useState("");
  const [name, setName] = useState("");
  const [state, setState] = useState("idle"); // idle | sending | sent | error

  if (!sub || !sub.enabled) return null;

  const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
  const submit = async () => {
    if (!interactive || state === "sending") return;
    const e = email.trim();
    if (!EMAIL_RE.test(e)) { setState("error"); return; }
    setState("sending");
    try {
      const r = await fetch("/api/u/" + encodeURIComponent(pseudo) + "/subscribe", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ email: e, name: sub.collectName ? (name.trim() || null) : null }),
      });
      if (r.ok) { setState("sent"); setEmail(""); setName(""); }
      else setState("error");
    } catch (err) { setState("error"); }
  };

  return (
    <section className="subscribe-card" data-card-id="subscribe" {...(cardOverrideProps(overrides, "subscribe") || {})} aria-label={t("subscribe.aria", null, "Inscription par email")}>
      <div className="subscribe-head">
        <span className="subscribe-icon" aria-hidden="true">
          <svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor"
               strokeWidth="1.9" strokeLinecap="round" strokeLinejoin="round">
            <rect x="3" y="5" width="18" height="14" rx="3" />
            <path d="M4 7l8 6 8-6" />
          </svg>
        </span>
        <div className="subscribe-copy">
          <div className="subscribe-title">{sub.title}</div>
          {sub.subtitle && <div className="subscribe-sub">{sub.subtitle}</div>}
        </div>
      </div>

      {state === "sent" ? (
        <div className="subscribe-done">
          <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
               strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
            <path d="M20 6L9 17l-5-5" />
          </svg>
          <span>{sub.successMsg}</span>
        </div>
      ) : (
        <div className="subscribe-form">
          {sub.collectName && (
            <input className="subscribe-input" data-field-id="subscribe" {...(fieldOverrideProps(overrides, "subscribe") || {})} type="text" maxLength={60}
                   placeholder={sub.namePh || t("subscribe.name_ph", null, "Ton pr\u00e9nom (optionnel)")}
                   value={name}
                   onChange={(e) => setName(e.target.value)} />
          )}
          <div className="subscribe-row">
            <input className="subscribe-input subscribe-email" data-field-id="subscribe" {...(fieldOverrideProps(overrides, "subscribe") || {})} type="email" inputMode="email"
                   autoComplete="email" maxLength={254}
                   placeholder={sub.emailPh || t("subscribe.email_ph", null, "ton@email.com")}
                   value={email}
                   onChange={(e) => { setEmail(e.target.value); if (state === "error") setState("idle"); }}
                   onKeyDown={(e) => { if (e.key === "Enter") submit(); }} />
            <button type="button" className="subscribe-btn" data-button-id="subscribe" {...(buttonOverrideProps(overrides, "subscribe") || {})}
                    disabled={state === "sending"} onClick={submit}>
              {state === "sending" ? t("subscribe.sending", null, "\u2026") : sub.buttonLabel}
            </button>
          </div>
          {state === "error" && (
            <div className="subscribe-err">{t("subscribe.err", null, "Adresse invalide. R\u00e9essaie.")}</div>
          )}
          <div className="subscribe-note">{t("subscribe.privacy", null, "Ton adresse reste priv\u00e9e, jamais partag\u00e9e.")}</div>
        </div>
      )}
    </section>
  );
}

function VerseSection({ verse, overrides }) {
  if (!verse || !verse.body) return null;
  const hasMeta = verse.reference || verse.translation;
  return (
    <section className="verse-card" data-card-id="verse" {...(cardOverrideProps(overrides, "verse") || {})} aria-label="Verset">
      <div className="verse-quote">{"\u201C" + verse.body + "\u201D"}</div>
      {hasMeta && (
        <div className="verse-ref">
          {verse.reference || ""}{verse.reference && verse.translation ? " \u00b7 " : ""}{verse.translation || ""}
        </div>
      )}
    </section>
  );
}

function CircleComments({ pseudo, post, me, onCountChange, overrides }) {
  const t = useI18n();
  const [list, setList] = useState(null);
  const [body, setBody] = useState("");
  const [busy, setBusy] = useState(false);
  const base = "/api/u/" + encodeURIComponent(pseudo) + "/circle/posts/" + post.id + "/comments";
  const load = () => {
    fetch(base).then((r) => (r.ok ? r.json() : { comments: [] })).then((d) => setList(d.comments || [])).catch(() => setList([]));
  };
  useEffect(() => { load(); }, [post.id]);
  const send = () => {
    const b = body.trim();
    if (!b || busy) return;
    setBusy(true);
    fetch(base, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: b }) })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(() => { setBody(""); load(); if (onCountChange) onCountChange(1); })
      .catch(() => {})
      .finally(() => setBusy(false));
  };
  const del = (id) => {
    fetch("/api/circle/comments/" + id, { method: "DELETE", credentials: "same-origin" }).then(() => { load(); if (onCountChange) onCountChange(-1); }).catch(() => {});
  };
  const isOwner = me && me.pseudo === pseudo;
  return (
    <div className="circle-comments">
      {list === null ? null : list.length === 0 ? (
        <div className="circle-cmuted">{t("circle.no_comments", null, "Aucun commentaire.")}</div>
      ) : list.map((c) => (
        <div className="circle-comment" key={c.id}>
          <span className="circle-cauthor">{c.author}</span>
          <span className="circle-cbody">{c.body}</span>
          {me && (me.id === c.authorId || isOwner) && <button className="circle-cdel" onClick={() => del(c.id)}>{t("circle.delete", null, "Supprimer")}</button>}
        </div>
      ))}
      {me ? (
        <div className="circle-cform">
          <input className="circle-cinput" data-field-id="circle" {...(fieldOverrideProps(overrides, "circle") || {})} type="text" maxLength="500" value={body}
                 placeholder={t("circle.comment_ph", null, "Commenter\u2026")}
                 onChange={(e) => setBody(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") send(); }} />
          <button className="circle-csend" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} disabled={busy} onClick={send}>{t("circle.send", null, "Envoyer")}</button>
        </div>
      ) : (
        <div className="circle-cmuted">{t("circle.login_to_comment", null, "Connecte-toi pour commenter.")} <a href="/login">{t("circle.login", null, "Connexion")}</a></div>
      )}
    </div>
  );
}

function CirclePost({ pseudo, post, me, onRemoved, overrides }) {
  const t = useI18n();
  const [liked, setLiked] = useState(post.liked);
  const [likes, setLikes] = useState(post.likes);
  const [pinned, setPinned] = useState(post.pinned);
  const [openC, setOpenC] = useState(false);
  const [cc, setCc] = useState(post.commentCount);
  const isOwner = me && me.pseudo === pseudo;
  const canDelete = me && (isOwner || me.id === post.authorId);

  const like = () => {
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/circle/posts/" + post.id + "/like", { method: "POST", credentials: "same-origin" })
      .then((r) => (r.ok ? r.json() : Promise.reject())).then((d) => { setLiked(d.liked); setLikes(d.likes); }).catch(() => {});
  };
  const del = () => {
    if (!window.confirm(t("circle.delete_confirm", null, "Supprimer ce message ?"))) return;
    fetch("/api/circle/posts/" + post.id, { method: "DELETE", credentials: "same-origin" }).then((r) => { if (r.ok && onRemoved) onRemoved(); }).catch(() => {});
  };
  const pin = () => {
    fetch("/api/circle/posts/" + post.id + "/pin", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ pinned: !pinned }) })
      .then((r) => (r.ok ? r.json() : Promise.reject())).then(() => setPinned(!pinned)).catch(() => {});
  };
  const report = () => {
    if (!window.confirm(t("circle.report_confirm", null, "Signaler ce message ?"))) return;
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/circle/report", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targetType: "post", targetId: post.id }) }).catch(() => {});
  };

  return (
    <div className="circle-post">
      <div className="circle-post-head">
        <span className="circle-author">{post.author}</span>
        {post.byOwner && <span className="circle-badge" data-badge-id="circle" {...(badgeOverrideProps(overrides, "circle") || {})}>{t("circle.creator", null, "cr\u00e9ateur")}</span>}
        {pinned && <span className="circle-pin">{t("circle.pinned", null, "\u00e9pingl\u00e9")}</span>}
      </div>
      <div className="circle-body">{post.body}</div>
      {post.imageUrl && <img className="circle-img" src={post.imageUrl} alt="" loading="lazy" />}
      <div className="circle-actions">
        <button className={"circle-btn" + (liked ? " is-liked" : "")} data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={like}>{t("circle.like", null, "J'aime") + " \u00b7 " + likes}</button>
        <button className="circle-btn" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={() => setOpenC((v) => !v)}>{t("circle.comments", null, "Commentaires") + " \u00b7 " + cc}</button>
        {canDelete && <button className="circle-btn no" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={del}>{t("circle.delete", null, "Supprimer")}</button>}
        {isOwner && <button className="circle-btn" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={pin}>{pinned ? t("circle.unpin", null, "D\u00e9s\u00e9pingler") : t("circle.pin", null, "\u00c9pingler")}</button>}
        {!canDelete && <button className="circle-btn" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={report}>{t("circle.report", null, "Signaler")}</button>}
      </div>
      {openC && <CircleComments pseudo={pseudo} post={post} me={me} onCountChange={(d) => setCc((n) => Math.max(0, n + d))} overrides={overrides} />}
    </div>
  );
}

function CircleSection({ circle, pseudo, interactive, overrides }) {
  const t = useI18n();
  const [me, setMe] = useState(undefined);
  const [posts, setPosts] = useState([]);
  const [nextOffset, setNextOffset] = useState(null);
  const [loading, setLoading] = useState(true);
  const [body, setBody] = useState("");
  const [imageUrl, setImageUrl] = useState(null);
  const [busy, setBusy] = useState(false);
  const [err, setErr] = useState("");
  const fileRef = useRef(null);

  const loadFeed = (offset, append) => {
    setLoading(true);
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/circle" + (offset ? "?offset=" + offset : ""))
      .then((r) => (r.ok ? r.json() : { posts: [] }))
      .then((d) => { setPosts((prev) => (append ? prev.concat(d.posts || []) : (d.posts || []))); setNextOffset(d.nextOffset); })
      .catch(() => {})
      .finally(() => setLoading(false));
  };

  useEffect(() => {
    if (!interactive || !circle || !circle.enabled) return;
    fetch("/api/me", { credentials: "same-origin" }).then((r) => (r.ok ? r.json() : null)).then((j) => setMe(j && j.user ? j.user : null)).catch(() => setMe(null));
    loadFeed(0, false);
  }, [interactive, pseudo]);

  if (!circle || !circle.enabled) return null;

  if (!interactive) {
    return (
      <section className="circle-card" data-card-id="circle" {...(cardOverrideProps(overrides, "circle") || {})} aria-label="Communauté">
        <div className="circle-title">{t("circle.title", null, "Communaut\u00e9")}</div>
        <div className="circle-cmuted">{t("circle.preview_note", null, "Ton espace communaut\u00e9 s'affiche ici sur ta page publique.")}</div>
      </section>
    );
  }

  const onFile = (e) => {
    const f = e.target.files && e.target.files[0];
    if (!f) return;
    setErr(""); setBusy(true);
    const fd = new FormData(); fd.append("file", f);
    fetch("/api/upload", { method: "POST", credentials: "same-origin", body: fd })
      .then((r) => r.json().then((d) => ({ ok: r.ok, d })))
      .then(({ ok, d }) => { if (ok && d.url) setImageUrl(d.url); else setErr(t("circle.upload_fail", null, "\u00c9chec de l'image.")); })
      .catch(() => setErr(t("circle.upload_fail", null, "\u00c9chec de l'image.")))
      .finally(() => setBusy(false));
  };
  const submit = () => {
    const b = body.trim();
    if (!b || busy) return;
    setBusy(true); setErr("");
    fetch("/api/u/" + encodeURIComponent(pseudo) + "/circle/posts", { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ body: b, imageUrl }) })
      .then((r) => (r.ok ? r.json() : Promise.reject()))
      .then(() => { setBody(""); setImageUrl(null); loadFeed(0, false); })
      .catch(() => setErr(t("circle.post_fail", null, "Publication impossible.")))
      .finally(() => setBusy(false));
  };

  return (
    <section className="circle-card" data-card-id="circle" {...(cardOverrideProps(overrides, "circle") || {})} aria-label="Communauté">
      <div className="circle-title">{t("circle.title", null, "Communaut\u00e9")}</div>
      {me ? (
        <div className="circle-composer">
          <textarea className="circle-input" data-field-id="circle" maxLength="600" value={body}
                    placeholder={t("circle.composer_ph", null, "\u00c9cris quelque chose\u2026")}
                    onChange={(e) => setBody(e.target.value)} />
          {imageUrl && <img className="circle-prev" src={imageUrl} alt="" />}
          {err && <div className="circle-err">{err}</div>}
          <div className="circle-composer-actions">
            <input ref={fileRef} type="file" accept="image/*" style={{ display: "none" }} onChange={onFile} />
            <button className="circle-btn" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} onClick={() => fileRef.current && fileRef.current.click()}>{busy ? "\u2026" : (imageUrl ? t("circle.change_image", null, "Changer l'image") : t("circle.add_image", null, "Image"))}</button>
            <button className="circle-btn is-primary" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} disabled={busy} onClick={submit}>{t("circle.publish", null, "Publier")}</button>
          </div>
        </div>
      ) : me === null ? (
        <div className="circle-cmuted">{t("circle.login_to_post", null, "Connecte-toi pour participer.")} <a href="/login">{t("circle.login", null, "Connexion")}</a></div>
      ) : null}

      <div className="circle-list">
        {posts.length === 0 && !loading ? (
          <div className="circle-cmuted">{t("circle.empty", null, "Pas encore de message. Lance la discussion !")}</div>
        ) : posts.map((p) => <CirclePost key={p.id} pseudo={pseudo} post={p} me={me} onRemoved={() => loadFeed(0, false)} overrides={overrides} />)}
      </div>
      {nextOffset != null && (
        <button className="circle-more" data-button-id="circle" {...(buttonOverrideProps(overrides, "circle") || {})} disabled={loading} onClick={() => loadFeed(nextOffset, true)}>{t("circle.more", null, "Voir plus")}</button>
      )}
    </section>
  );
}

function WalletSection({ wallet, interactive, overrides }) {  const t = useI18n();
  if (!wallet || !Array.isArray(wallet.addresses) || wallet.addresses.length === 0) return null;
  return (
    <section className="wallet-card" data-card-id="wallet" {...(cardOverrideProps(overrides, "wallet") || {})} aria-label="Wallet">
      <div className="wallet-head">
        <div className="wallet-title">
          {wallet.title || t("wallet.title", null, "Me soutenir en crypto")}
        </div>
        {wallet.note && <div className="wallet-note">{wallet.note}</div>}
      </div>
      <div className="wallet-list">
        {wallet.addresses.map((a) => (
          <WalletAddress key={a.id} a={a}
                         connectEnabled={wallet.connectEnabled !== false}
                         interactive={interactive} overrides={overrides} />
        ))}
      </div>
      <div className="wallet-disclaimer">
        {t("wallet.disclaimer", null, "Sending crypto is irreversible. Always double-check the address and network before sending.")}
      </div>
      <div className="wallet-fees">
        {t("wallet.fees", null, "Stanmaxx holds no crypto and takes no fees \u2014 funds go straight wallet-to-wallet.")}
      </div>
    </section>
  );
}

function ProjectsSection({ projects, interactive, overrides }) {
  const t = useI18n();
  if (!Array.isArray(projects) || projects.length === 0) return null;
  return (
    <section className="prj-section" aria-label="Projets">
      <div className="prj-section-title">{t("prj.title", null, "Projets")}</div>
      {projects.map((p) => <ProjectCard p={p} key={p.id} interactive={interactive}  overrides={overrides} />)}
    </section>
  );
}

// ── SportsBadges ─────────────────────────────────────────────────────────────
// Owner-toggled sports, as a row of small pills, each with its own icon.
// Keys are whitelisted server-side; names come from i18n.
function SportsBadges({ sports, overrides }) {
  const t = useI18n();
  const items = (Array.isArray(sports) ? sports : []).slice(0, 3);
  if (items.length === 0) return null;
  const ov = badgeOverrideProps(overrides, "sports") || {};
  return (
    <div className="sports-row">
      {items.map((key) => (
        <span className="sports-pill" data-badge-id="sports" {...ov} key={key}>
          <SportIcon sport={key} />
          {t("sport." + key, null, key)}
        </span>
      ))}
    </div>
  );
}

// ── WcPredsBadge ─────────────────────────────────────────────────────────────
// "Pronos" pill on profile pages: tap to unfold this person's prediction
// history. Only locked matches are public (the server enforces it), so
// nobody can copy picks for upcoming games.
function WcPredsBadge({ pseudo, count, interactive, overrides }) {
  const t = useI18n();
  const [open, setOpen] = useState(false);
  const [data, setData] = useState(null);
  if (!count) return null;
  const toggle = () => {
    if (!interactive) return;
    const next = !open;
    setOpen(next);
    if (next && !data) {
      fetch(`/api/worldcup/user/${encodeURIComponent(pseudo)}`)
        .then((r) => r.ok ? r.json() : null)
        .then((d) => { if (d) setData(d); })
        .catch(() => {});
    }
  };
  return (
    <div className="wcpreds">
      <button type="button" className="wcpreds-btn" data-button-id="wcpreds" {...(buttonOverrideProps(overrides, "wcpreds") || {})} onClick={toggle} aria-expanded={open}>
        <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
             strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <circle cx="12" cy="12" r="8.5" />
          <path d="M12 8l3.8 2.8-1.45 4.4h-4.7L8.2 10.8z" />
        </svg>
        <span>{t("linktree.preds", null, "Pronos")}</span>
        <span className="wcpreds-count" data-badge-id="wcpreds" {...(badgeOverrideProps(overrides, "wcpreds") || {})}>{count}</span>
      </button>
      {open && (
        <div className="wcpreds-panel">
          {!data ? (
            <div className="wcpreds-empty">{"\u2026"}</div>
          ) : (
            <>
              <div className="wcpreds-totals">
                {data.totals.points} pts {"\u00b7"} {data.totals.exacts} {t("wc.exacts", null, "scores exacts")}
              </div>
              {data.items.map((it, i) => {
                const done = it.scoreA != null && it.scoreB != null;
                return (
                  <div className="wcpreds-row" key={i}>
                    <img className="wcpreds-flag" src={countryFlagUrl(it.teamA)} alt="" loading="lazy" />
                    <span className="wcpreds-score">
                      {done ? it.scoreA + "\u2013" + it.scoreB : "\u2013"}
                    </span>
                    <img className="wcpreds-flag" src={countryFlagUrl(it.teamB)} alt="" loading="lazy" />
                    <span className="wcpreds-my">
                      {t("wc.your_pred_short", null, "prono")} {it.myA}{"\u2013"}{it.myB}
                    </span>
                    {it.pts !== null ? (
                      <span className={`wcpreds-pts ${it.pts === 3 ? "is-exact" : it.pts === 0 ? "is-zero" : ""}`}>
                        +{it.pts}
                      </span>
                    ) : (
                      <span className="wcpreds-pts is-zero">
                        {it.locked
                          ? t("wc.pending", null, "en cours")
                          : t("wc.upcoming", null, "\u00c0 venir")}
                      </span>
                    )}
                  </div>
                );
              })}
              <a className="wcpreds-link" href="/pronos">{t("wc.title", null, "Pronos")} {"\u2192"}</a>
            </>
          )}
        </div>
      )}
    </div>
  );
}

// ── ZodiacBadge ──────────────────────────────────────────────────────────────
// Owner-toggled zodiac sign: a small glass pill with the sign's glyph and its
// localized name. Sign keys are whitelisted server-side.
const ZODIAC_GLYPHS = {
  aries: "\u2648", taurus: "\u2649", gemini: "\u264A", cancer: "\u264B",
  leo: "\u264C", virgo: "\u264D", libra: "\u264E", scorpio: "\u264F",
  sagittarius: "\u2650", capricorn: "\u2651", aquarius: "\u2652", pisces: "\u2653",
};
function ZodiacBadge({ sign, overrides }) {
  const t = useI18n();
  const glyph = ZODIAC_GLYPHS[sign];
  if (!glyph) return null;
  const name = t("zodiac." + sign, null, sign);
  return (
    <div className="zodiac-row">
      <div className="zodiac-badge" data-badge-id="zodiac" {...(badgeOverrideProps(overrides, "zodiac") || {})} title={name}>
        <span className="zodiac-glyph" aria-hidden="true">{glyph}</span>
        <span className="zodiac-name">{name}</span>
      </div>
    </div>
  );
}

// ── ArticlesSection ──────────────────────────────────────────────────────────
// Published owner-written articles, rendered as cards under the links. Each
// card expands in place to show the full text (plain text, paragraph breaks
// preserved; React escapes everything). Hidden when there are no articles.
function ArticlesSection({ articles }) {
  const t = useI18n();
  const [openId, setOpenId] = useState(null);
  const items = Array.isArray(articles) ? articles.filter((a) => a && a.title) : [];
  if (items.length === 0) return null;
  const fmtDate = (iso) => {
    try {
      return new Date(iso.replace(" ", "T") + "Z").toLocaleDateString(undefined,
        { day: "numeric", month: "short", year: "numeric" });
    } catch { return ""; }
  };
  return (
    <section className="articles" aria-label={t("linktree.articles", null, "Articles")}>
      <div className="articles-title">{t("linktree.articles", null, "Articles")}</div>
      {items.map((a) => {
        const open = openId === a.id;
        const paras = String(a.body || "").split(/\n{2,}/).filter((p) => p.trim());
        return (
          <article className={`article-card ${open ? "is-open" : ""}`} key={a.id}>
            <button type="button" className="article-head" onClick={() => setOpenId(open ? null : a.id)}
                    aria-expanded={open}>
              <span className="article-headtext">
                <span className="article-title">{a.title}</span>
                <span className="article-date">{fmtDate(a.createdAt)}</span>
              </span>
              <svg className="article-caret" viewBox="0 0 24 24" width="14" height="14" aria-hidden="true">
                <path d="M6 9l6 6 6-6" fill="none" stroke="currentColor" strokeWidth="2"
                      strokeLinecap="round" strokeLinejoin="round" />
              </svg>
            </button>
            <div className="article-body">
              {paras.map((p, i) => <p key={i}>{p}</p>)}
            </div>
          </article>
        );
      })}
    </section>
  );
}

// ── Event ───────────────────────────────────────────────────────────────────
function EventCard({ event, overrides }) {
  const t = useI18n();
  if (!event || !event.enabled) return null;
  return (
    <a className="event glass" data-card-id="event" {...(cardOverrideProps(overrides, "event") || {})} href={event.href || "#"}>
      <div className="event-date">
        <span className="event-d">{event.date?.d || ""}</span>
        <span className="event-m">{event.date?.m || ""}</span>
      </div>
      <div className="event-body">
        {event.badge && (
          <span className="event-badge">
            <span className="event-pulse" />
            {event.badge}
          </span>
        )}
        <div className="event-title">{event.title}</div>
        <div className="event-sub">{event.subtitle}</div>
      </div>
      <div className="event-cta" data-button-id="event" {...(buttonOverrideProps(overrides, "event") || {})}>
        {event.cta || t("linktree.event_cta_fallback", null, "Voir")}
        <svg viewBox="0 0 16 16" width="12" height="12" fill="none"
             stroke="currentColor" strokeWidth="2" strokeLinecap="round"
             strokeLinejoin="round">
          <path d="M5 3l5 5-5 5" />
        </svg>
      </div>
    </a>
  );
}

// ── House entrance ───────────────────────────────────────────────────────────
// A prominent card in the body of the public profile that invites visitors
// to step into the owner's companion house. The shareable link stays unique
// (stanmaxx.com/pseudo) — this card is how a visitor who only has that one
// link discovers and reaches the house at /pseudo/house. We only render it
// when the owner has enabled their house (data.houseEnabled). Styled as a
// glass card to match EventCard, with a cozy little house glyph so it reads
// as "a place" rather than just another link.
function HouseEntrance({ pseudo, enabled, overrides }) {
  const t = useI18n();
  if (!enabled || !pseudo) return null;
  return (
    <a className="house-entrance glass" data-card-id="house" {...(cardOverrideProps(overrides, "house") || {})} href={`/${pseudo}/house`}>
      <div className="house-entrance-icon" aria-hidden="true">
        <svg viewBox="0 0 48 48" width="34" height="34" fill="none">
          {/* little cozy house — black outline, warm fill, lit window */}
          <path d="M8 22L24 9l16 13" stroke="currentColor" strokeWidth="2.4"
                strokeLinecap="round" strokeLinejoin="round"/>
          <path d="M11 20v17a1.5 1.5 0 0 0 1.5 1.5h23A1.5 1.5 0 0 0 37 37V20"
                stroke="currentColor" strokeWidth="2.4"
                strokeLinecap="round" strokeLinejoin="round"/>
          <rect x="20.5" y="28" width="7" height="10.5" rx="1"
                stroke="currentColor" strokeWidth="2.2"/>
          <rect x="14.5" y="24" width="5" height="5" rx="1"
                fill="currentColor" opacity=".55"/>
          <rect x="28.5" y="24" width="5" height="5" rx="1"
                fill="currentColor" opacity=".55"/>
        </svg>
      </div>
      <div className="house-entrance-body">
        <div className="house-entrance-title">
          {t("view.house_entrance_title", null, "Ma maison")}
        </div>
        <div className="house-entrance-sub">
          {t("view.house_entrance_sub", null, "Entre, laisse un mot dans la boîte aux lettres")}
        </div>
      </div>
      <div className="house-entrance-cta" data-button-id="house" {...(buttonOverrideProps(overrides, "house") || {})}>
        {t("view.house_entrance_cta", null, "Entrer")}
        <svg viewBox="0 0 16 16" width="12" height="12" fill="none"
             stroke="currentColor" strokeWidth="2" strokeLinecap="round"
             strokeLinejoin="round">
          <path d="M5 3l5 5-5 5" />
        </svg>
      </div>
    </a>
  );
}

// ── Store entrance ───────────────────────────────────────────────────────────
// Sibling of HouseEntrance: a glass card on the public link page that takes a
// visitor to the owner's WhatsApp store at /:pseudo/store. Rendered only when
// the store is enabled AND the owner chose to surface it on the link page
// (storeOnLinkpage). Shows the shop's name when set, falling back to a generic
// label. The little bag/storefront glyph reads as "a shop".
function StoreEntrance({ pseudo, enabled, onLinkpage, storeName, overrides }) {
  const t = useI18n();
  if (!enabled || !onLinkpage || !pseudo) return null;
  const title = (storeName && storeName.trim())
    ? storeName
    : t("view.store_entrance_title", null, "Ma boutique");
  return (
    <a className="store-entrance glass" data-card-id="store" {...(cardOverrideProps(overrides, "store") || {})} href={`/${pseudo}/store`}>
      <div className="store-entrance-icon" aria-hidden="true">
        <svg viewBox="0 0 48 48" width="32" height="32" fill="none">
          {/* storefront: awning + counter, black outline to match house glyph */}
          <path d="M9 18l2.4-6.5a2 2 0 0 1 1.9-1.3h21.4a2 2 0 0 1 1.9 1.3L39 18"
                stroke="currentColor" strokeWidth="2.4"
                strokeLinecap="round" strokeLinejoin="round"/>
          <path d="M11 18v18a1.6 1.6 0 0 0 1.6 1.6h22.8A1.6 1.6 0 0 0 37 36V18"
                stroke="currentColor" strokeWidth="2.4"
                strokeLinecap="round" strokeLinejoin="round"/>
          <path d="M9 18h30" stroke="currentColor" strokeWidth="2.4"
                strokeLinecap="round" strokeLinejoin="round"/>
          <rect x="20.5" y="26" width="7" height="11.6" rx="1"
                stroke="currentColor" strokeWidth="2.2"/>
          <path d="M16 18v3.5M24 18v3.5M32 18v3.5"
                stroke="currentColor" strokeWidth="1.6" opacity=".55"
                strokeLinecap="round"/>
        </svg>
      </div>
      <div className="store-entrance-body">
        <div className="store-entrance-title">{title}</div>
        <div className="store-entrance-sub">
          {t("view.store_entrance_sub", null, "Commande directement sur WhatsApp")}
        </div>
      </div>
      <div className="store-entrance-cta" data-button-id="store" {...(buttonOverrideProps(overrides, "store") || {})}>
        {t("view.store_entrance_cta", null, "Voir")}
        <svg viewBox="0 0 16 16" width="12" height="12" fill="none"
             stroke="currentColor" strokeWidth="2" strokeLinecap="round"
             strokeLinejoin="round">
          <path d="M5 3l5 5-5 5" />
        </svg>
      </div>
    </a>
  );
}

// ── Links ───────────────────────────────────────────────────────────────────
// ── Studio ───────────────────────────────────────────────────────────────────
// Turns the owner's studio settings into props for the page root: `data-*`
// attributes that switch on the scoped CSS variants in glass.css, plus inline
// custom properties for the colours. Keys absent from the payload mean "auto",
// so nothing is emitted for them and the page renders exactly as it always has.
//
// Everything is re-validated here as well as on the server: enum values must be
// plain identifiers and colours must be hex, because these strings end up in
// attribute selectors and CSS declarations.
const STUDIO_ATTRS = {
  fontDisplay: "data-font-display",
  fontBody: "data-font-body",
  fontScale: "data-font-scale",
  titleWeight: "data-title-weight",
  letterSpace: "data-letter-space",
  fontLinks: "data-font-links",
  linkWeight: "data-link-weight",
  textCase: "data-text-case",
  linkStyle: "data-link-style",
  linkRadius: "data-link-radius",
  linkDensity: "data-link-density",
  linkIcon: "data-link-icon",
  linkArrow: "data-link-arrow",
  linkAlign: "data-link-align",
  iconShape: "data-icon-shape",
  cardStyle: "data-card-style",
  cardRadius: "data-card-radius",
  cardEdge: "data-card-edge",
  badgeStyle: "data-badge-style",
  badgeRadius: "data-badge-radius",
  buttonStyle: "data-button-style",
  buttonRadius: "data-button-radius",
  socialStyle: "data-social-style",
  socialShape: "data-social-shape",
  badgeFont: "data-badge-font",
  buttonFont: "data-button-font",
  badgeWeight: "data-badge-weight",
  buttonWeight: "data-button-weight",
  badgeCase: "data-badge-case",
  buttonCase: "data-button-case",
  fieldStyle: "data-field-style",
  shadowDepth: "data-shadow-depth",
  linkShadow: "data-link-shadow",
  linkBorderW: "data-link-border-w",
  iconSize: "data-icon-size",
  cardDensity: "data-card-density",
  cardShadow: "data-card-shadow",
  badgeSize: "data-badge-size",
  buttonSize: "data-button-size",
  buttonWidth: "data-button-width",
  socialSize: "data-social-size",
  fieldRadius: "data-field-radius",
  nameSize: "data-name-size",
  photoShadow: "data-photo-shadow",
  photoEdge: "data-photo-edge",
  contentAlign: "data-content-align",
  pageWidth: "data-page-width",
  linkGradAngle: "data-link-grad",
  blockGap: "data-block-gap",
  photoShape: "data-photo-shape",
  photoMode: "data-photo-mode",
  photoSize: "data-photo-size",
  cardGradAngle: "data-card-grad",
  badgeGradAngle: "data-badge-grad",
  buttonGradAngle: "data-button-grad",
  socialGradAngle: "data-social-grad",
  fieldGradAngle: "data-field-grad",
  iconGradAngle: "data-icon-grad",
  photoGradAngle: "data-photo-grad",
};
const STUDIO_COLORS = {
  inkColor: "--sf-ink",
  linkBgColor: "--sf-link-bg",
  linkEdgeColor: "--sf-link-edge",
  cardBgColor: "--sf-card-bg",
  accentColor: "--sf-accent",
  badgeColor: "--sf-badge-bg",
  buttonColor: "--sf-button-bg",
  socialColor: "--sf-social-bg",
  fieldColor: "--sf-field-bg",
  linkInkColor: "--sf-link-ink",
  cardInkColor: "--sf-card-ink",
  cardEdgeColor: "--sf-card-edge",
  badgeInkColor: "--sf-badge-ink",
  badgeEdgeColor: "--sf-badge-edge",
  buttonInkColor: "--sf-button-ink",
  buttonEdgeColor: "--sf-button-edge",
  socialInkColor: "--sf-social-ink",
  fieldInkColor: "--sf-field-ink",
  socialEdgeColor: "--sf-social-edge",
  fieldEdgeColor: "--sf-field-edge",
  iconBgColor: "--sf-icon-bg",
  iconInkColor: "--sf-icon-ink",
  photoEdgeColor: "--sf-photo-edge",
  linkShadowColor: "--sf-link-shadow",
  linkGradFrom: "--sf-link-g1",
  linkGradTo: "--sf-link-g2",
  linkGradMid: "--sf-link-g3",
  cardShadowColor: "--sf-card-shadow",
  buttonShadowColor: "--sf-button-shadow",
  badgeShadowColor: "--sf-badge-shadow",
  linkGlowColor: "--sf-link-glow",
  cardGlowColor: "--sf-card-glow",
  cardGradFrom: "--sf-card-g1",
  cardGradTo: "--sf-card-g2",
  cardGradMid: "--sf-card-g3",
  badgeGlowColor: "--sf-badge-glow",
  badgeGradFrom: "--sf-badge-g1",
  badgeGradTo: "--sf-badge-g2",
  badgeGradMid: "--sf-badge-g3",
  buttonGlowColor: "--sf-button-glow",
  buttonGradFrom: "--sf-button-g1",
  buttonGradTo: "--sf-button-g2",
  buttonGradMid: "--sf-button-g3",
  socialGlowColor: "--sf-social-glow",
  socialGradFrom: "--sf-social-g1",
  socialGradTo: "--sf-social-g2",
  socialGradMid: "--sf-social-g3",
  fieldGlowColor: "--sf-field-glow",
  fieldGradFrom: "--sf-field-g1",
  fieldGradTo: "--sf-field-g2",
  fieldGradMid: "--sf-field-g3",
  iconGlowColor: "--sf-icon-glow",
  iconGradFrom: "--sf-icon-g1",
  iconGradTo: "--sf-icon-g2",
  iconGradMid: "--sf-icon-g3",
  photoGlowColor: "--sf-photo-glow",
  photoGradFrom: "--sf-photo-g1",
  photoGradTo: "--sf-photo-g2",
  photoGradMid: "--sf-photo-g3",
};
// Mirrors BLOCKS in studio.js.
const STUDIO_BLOCKS = ["event", "business", "coin", "projects", "verse", "links",
                       "subscribe", "wallet", "engage", "circle", "articles",
                       "house", "store"];
const // Enum values must start with a letter — except the gradient angles, which
// are plain numbers ("90", "180"). The old pattern rejected every one of them
// silently, so no gradient direction ever reached the page.
STUDIO_ENUM_RE = /^(?:[a-z][a-z0-9]{0,15}|[0-9]{1,3})$/;
const STUDIO_HEX_RE = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;

function studioProps(studio, page) {
  const out = { className: "page" };
  // Page-level finish from the Style tab (patterns, dark mode, entry motion).
  // Kept in the same attribute bag so there is one place that decides what the
  // page root carries.
  if (page && typeof page === "object") {
    if (["grain","grid","dots","mesh","waves"].includes(page.bgPattern)) out["data-bg-pattern"] = page.bgPattern;
    if (["auto","on"].includes(page.darkMode)) out["data-dark"] = page.darkMode;
    if (["fade","rise"].includes(page.pageMotion)) out["data-motion"] = page.pageMotion;
  }
  if (!studio || typeof studio !== "object") return out;
  for (const [key, attr] of Object.entries(STUDIO_ATTRS)) {
    const v = studio[key];
    if (typeof v === "string" && v !== "auto" && STUDIO_ENUM_RE.test(v)) out[attr] = v;
  }
  let style = null;
  for (const [key, cssVar] of Object.entries(STUDIO_COLORS)) {
    const v = studio[key];
    if (typeof v === "string" && STUDIO_HEX_RE.test(v)) {
      style = style || {};
      style[cssVar] = v;
    }
  }
  // Block order: one custom property per block, read by the `order` rules in
  // glass.css. Ids are re-checked against the known list because they end up
  // in property names.
  if (Array.isArray(studio.blockOrder)) {
    let n = 0;
    for (const id of studio.blockOrder) {
      if (typeof id !== "string" || !STUDIO_BLOCKS.includes(id)) continue;
      style = style || {};
      style["--o-" + id] = String(++n * 10);
    }
  }
  if (style) out.style = style;
  return out;
}

// Attributes and inline custom properties for a single link that carries its
// own overrides. Mirrors studioProps, and re-validates for the same reason:
// these values end up in attribute selectors and CSS declarations.
const LINK_OVERRIDE_ATTRS = {
  linkGradAngle: "data-link-grad",
  linkStyle: "data-link-style",
  linkRadius: "data-link-radius",
  linkDensity: "data-link-density",
  iconShape: "data-icon-shape",
  linkShadow: "data-link-shadow",
  linkBorderW: "data-link-border-w",
  iconSize: "data-icon-size",
  iconGradAngle: "data-icon-grad",
};
// Only the fields OVERRIDE_FIELDS.link actually allows (see studio.js) ever
// reach `ov`, so this map only needs those. It used to also carry
// badge/button/social/field/card keys that a link override can never hold —
// harmless clutter, except the duplicate iconBgColor/iconInkColor/linkInkColor
// entries silently overwrote themselves, and linkGlowColor/iconGlowColor were
// missing outright, which is why a link or its icon could never show a glow.
const LINK_OVERRIDE_COLORS = {
  linkBgColor: "--sf-link-bg",
  linkEdgeColor: "--sf-link-edge",
  linkInkColor: "--sf-link-ink",
  linkShadowColor: "--sf-link-shadow",
  linkGlowColor: "--sf-link-glow",
  linkGradFrom: "--sf-link-g1",
  linkGradTo: "--sf-link-g2",
  linkGradMid: "--sf-link-g3",
  iconBgColor: "--sf-icon-bg",
  iconInkColor: "--sf-icon-ink",
  iconGlowColor: "--sf-icon-glow",
  iconGradFrom: "--sf-icon-g1",
  iconGradTo: "--sf-icon-g2",
  iconGradMid: "--sf-icon-g3",
  inkColor: "--sf-ink",
  accentColor: "--sf-accent",
};
// Per-card styling. The card families reuse the same shape as the per-link
// one; only the attribute and variable names differ, so one helper serves
// both rather than duplicating the walk.
const CARD_OVERRIDE_ATTRS = {
  cardStyle: "data-card-style",
  cardRadius: "data-card-radius",
  cardDensity: "data-card-density",
  cardShadow: "data-card-shadow",
  cardEdge: "data-card-edge",
  cardGradAngle: "data-card-grad",
};
const CARD_OVERRIDE_COLORS = {
  cardBgColor: "--sf-card-bg",
  cardEdgeColor: "--sf-card-edge",
  cardInkColor: "--sf-card-ink",
  cardShadowColor: "--sf-card-shadow",
  cardGlowColor: "--sf-card-glow",
  cardGradFrom: "--sf-card-g1",
  cardGradTo: "--sf-card-g2",
  cardGradMid: "--sf-card-g3",
};

function overrideProps(ov, attrs, colors) {
  if (!ov || typeof ov !== "object") return null;
  const out = {};
  let style = null;
  for (const [key, attr] of Object.entries(attrs)) {
    const v = ov[key];
    if (typeof v === "string" && v !== "auto" && STUDIO_ENUM_RE.test(v)) out[attr] = v;
  }
  for (const [key, cssVar] of Object.entries(colors)) {
    const v = ov[key];
    if (typeof v === "string" && STUDIO_HEX_RE.test(v)) {
      style = style || {};
      style[cssVar] = v;
    }
  }
  if (style) out.style = style;
  return Object.keys(out).length ? out : null;
}

// Per-badge and per-social styling. Same shape as the per-card one above:
// only the attribute and variable names differ, keyed to what OVERRIDE_FIELDS
// in studio.js allows for that kind.
const BADGE_OVERRIDE_ATTRS = {
  badgeStyle: "data-badge-style",
  badgeRadius: "data-badge-radius",
  badgeSize: "data-badge-size",
  badgeFont: "data-badge-font",
  badgeWeight: "data-badge-weight",
  badgeCase: "data-badge-case",
  badgeGradAngle: "data-badge-grad",
};
const BADGE_OVERRIDE_COLORS = {
  badgeColor: "--sf-badge-bg",
  badgeInkColor: "--sf-badge-ink",
  badgeEdgeColor: "--sf-badge-edge",
  badgeGlowColor: "--sf-badge-glow",
  badgeShadowColor: "--sf-badge-shadow",
  badgeGradFrom: "--sf-badge-g1",
  badgeGradTo: "--sf-badge-g2",
  badgeGradMid: "--sf-badge-g3",
};
const SOCIAL_OVERRIDE_ATTRS = {
  socialStyle: "data-social-style",
  socialShape: "data-social-shape",
  socialSize: "data-social-size",
  socialGradAngle: "data-social-grad",
};
const SOCIAL_OVERRIDE_COLORS = {
  socialColor: "--sf-social-bg",
  socialInkColor: "--sf-social-ink",
  socialEdgeColor: "--sf-social-edge",
  socialGlowColor: "--sf-social-glow",
  socialGradFrom: "--sf-social-g1",
  socialGradTo: "--sf-social-g2",
  socialGradMid: "--sf-social-g3",
};

const FIELD_OVERRIDE_ATTRS = {
  fieldStyle: "data-field-style",
  fieldRadius: "data-field-radius",
  fieldGradAngle: "data-field-grad",
};
const FIELD_OVERRIDE_COLORS = {
  fieldColor: "--sf-field-bg",
  fieldInkColor: "--sf-field-ink",
  fieldEdgeColor: "--sf-field-edge",
  fieldGlowColor: "--sf-field-glow",
  fieldGradFrom: "--sf-field-g1",
  fieldGradTo: "--sf-field-g2",
  fieldGradMid: "--sf-field-g3",
};

// Props for one field, looked up by its own slug (e.g. "subscribe", or
// "poll" for the poll-answer buttons, which share the Champs family so
// they get the same full style treatment instead of a fixed colour).
function fieldOverrideProps(overrides, id) {
  if (!overrides || !id) return null;
  return overrideProps(overrides["field:" + id], FIELD_OVERRIDE_ATTRS, FIELD_OVERRIDE_COLORS);
}

const BUTTON_OVERRIDE_ATTRS = {
  buttonStyle: "data-button-style",
  buttonRadius: "data-button-radius",
  buttonSize: "data-button-size",
  buttonWidth: "data-button-width",
  buttonFont: "data-button-font",
  buttonWeight: "data-button-weight",
  buttonCase: "data-button-case",
  buttonGradAngle: "data-button-grad",
};
const BUTTON_OVERRIDE_COLORS = {
  buttonColor: "--sf-button-bg",
  buttonInkColor: "--sf-button-ink",
  buttonEdgeColor: "--sf-button-edge",
  buttonGlowColor: "--sf-button-glow",
  buttonShadowColor: "--sf-button-shadow",
  buttonGradFrom: "--sf-button-g1",
  buttonGradTo: "--sf-button-g2",
  buttonGradMid: "--sf-button-g3",
};

// Props for one button, looked up by its own slug (e.g. "subscribe").
function buttonOverrideProps(overrides, id) {
  if (!overrides || !id) return null;
  return overrideProps(overrides["button:" + id], BUTTON_OVERRIDE_ATTRS, BUTTON_OVERRIDE_COLORS);
}

// Props for one badge, looked up by the badge's own slug (e.g. "zodiac").
function badgeOverrideProps(overrides, id) {
  if (!overrides || !id) return null;
  return overrideProps(overrides["badge:" + id], BADGE_OVERRIDE_ATTRS, BADGE_OVERRIDE_COLORS);
}

// Props for one social icon, looked up by its network (e.g. "instagram").
function socialOverrideProps(overrides, id) {
  if (!overrides || !id) return null;
  return overrideProps(overrides["social:" + id], SOCIAL_OVERRIDE_ATTRS, SOCIAL_OVERRIDE_COLORS);
}

// Props for one card block, looked up by the block's own name.
function cardOverrideProps(overrides, block) {
  if (!overrides || !block) return null;
  return overrideProps(overrides["card:" + block], CARD_OVERRIDE_ATTRS, CARD_OVERRIDE_COLORS);
}

function linkOverrideProps(ov) {
  if (!ov || typeof ov !== "object") return null;
  const out = {};
  let style = null;
  for (const [key, attr] of Object.entries(LINK_OVERRIDE_ATTRS)) {
    const v = ov[key];
    if (typeof v === "string" && v !== "auto" && STUDIO_ENUM_RE.test(v)) out[attr] = v;
  }
  for (const [key, cssVar] of Object.entries(LINK_OVERRIDE_COLORS)) {
    const v = ov[key];
    if (typeof v === "string" && STUDIO_HEX_RE.test(v)) {
      style = style || {};
      style[cssVar] = v;
    }
  }
  if (style) out.style = style;
  return Object.keys(out).length ? out : null;
}

// Rows come in two kinds: links, and section headings that group the links
// after them. Grouping is expressed by ORDER rather than nesting, so a heading
// can be moved between links and the existing reorder arrows keep working.
//
// A collapsible section has to LOOK collapsible: chevron, a count of what it
// holds, and the whole row as a hit area. A plain heading carries none of
// that, so the two read differently at a glance. Sections start open — a link
// nobody notices is a link nobody taps.
function SectionRow({ item, count, open, onToggle }) {
  const label = item.label || "";
  if (!item.collapsible) {
    return (
      <div className="link-section" role="heading" aria-level="2">
        <span className="link-section-label">{label}</span>
        <span className="link-section-rule" aria-hidden="true" />
      </div>
    );
  }
  return (
    <button type="button"
            className={"link-section is-foldable" + (open ? " is-open" : "")}
            aria-expanded={open} onClick={onToggle}>
      <span className="link-section-label">{label}</span>
      {count > 0 && <span className="link-section-count">{count}</span>}
      <span className="link-section-rule" aria-hidden="true" />
      <span className="link-section-chevron" aria-hidden="true">
        <svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor"
             strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">
          <path d="M6 9l6 6 6-6" />
        </svg>
      </span>
    </button>
  );
}

function Links({ items, overrides }) {
  if (!items || items.length === 0) return null;

  // Intercept the click so we can fire a tracking ping before navigation.
  // We use sendBeacon when available — it's specifically designed for this
  // case (browser keeps the request alive across navigation) and never
  // blocks the user. Modifier-clicks (cmd, ctrl, shift, middle) bypass the
  // tracker entirely so power users opening in new tabs aren't slowed.
  const onLinkClick = (e, item) => {
    // Let the browser handle "open in new tab", "open in new window",
    // right-click, copy-link, etc. — these don't navigate the current page
    // so we don't need to delay anything. We DO still fire a beacon so
    // the click is counted even when the user opens in a new tab.
    const isNewTab = e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1;
    if (typeof item.id === "number" && item.id > 0) {
      const body = JSON.stringify({ id: item.id });
      try {
        if (navigator.sendBeacon) {
          navigator.sendBeacon("/api/track/click",
            new Blob([body], { type: "application/json" }));
        } else {
          // Old browsers — fire-and-forget fetch. keepalive lets it survive
          // the navigation (when supported).
          fetch("/api/track/click", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body,
            keepalive: true,
          }).catch(() => {});
        }
      } catch { /* tracking failures must never block navigation */ }
    }
    // Don't preventDefault — let the native click do its thing. With
    // sendBeacon the ping fires from the browser even after we unload.
    void isNewTab; // documented intent above; no further action needed
  };

  // Folded state for collapsible sections, keyed by row position.
  const [folded, setFolded] = React.useState({});

  const renderLink = (item, i) => {
        const href = item.href || "#";
        // External destinations open in a new tab (linkpage standard, the
        // visitor keeps the page); internal paths and anchors stay here.
        const external = /^(https?:\/\/|mailto:|tel:)/i.test(href);
        return (
        <a key={item.id ?? item.label}
           className={"link glass" + (item.thumbUrl && item.thumbMode === "card" ? " has-card" : "")
                      + (item.thumbUrl && item.thumbMode === "tile" ? " has-tile" : "")}
           data-link-i={i}
           data-link-uid={item.uid || undefined}
           {...(linkOverrideProps(
             overrides && ((item.uid && overrides["link:" + item.uid]) || overrides["link:" + i])
           ) || {})}
           href={href}
           target={external ? "_blank" : undefined}
           rel={external ? "noopener noreferrer" : undefined}
           onClick={(e) => onLinkClick(e, item)}>
          {item.thumbUrl && item.thumbMode === "card" ? (
            <span className="link-card-img" aria-hidden="true">
              <img src={item.thumbUrl} alt="" loading="lazy" />
            </span>
          ) : null}
          {item.thumbUrl && item.thumbMode === "tile" ? (
            <span className="link-icon link-tile" aria-hidden="true">
              <img src={item.thumbUrl} alt="" loading="lazy" />
            </span>
          ) : (
            <LinkIcon name={item.icon} />
          )}
          <div className="link-body">
            <div className="link-label">{item.label}</div>
            {item.sub && <div className="link-sub">{item.sub}</div>}
          </div>
          <div className="link-arrow">
            <svg viewBox="0 0 16 16" width="12" height="12" fill="none"
                 stroke="currentColor" strokeWidth="2" strokeLinecap="round"
                 strokeLinejoin="round">
              <path d="M5 3l5 5-5 5" />
            </svg>
          </div>
        </a>
        );
        };

  return (
    <nav className="links">
      {(() => {
        const rows = [];
        let hidden = false;
        items.forEach((item, i) => {
          if (item.kind === "section") {
            let count = 0;
            for (let k = i + 1; k < items.length && items[k].kind !== "section"; k++) count++;
            const open = !folded[i];
            hidden = !!item.collapsible && !open;
            rows.push(
              <SectionRow key={"s" + i} item={item} count={count} open={open}
                          onToggle={() => setFolded((f) => ({ ...f, [i]: !f[i] }))} />
            );
            return;
          }
          if (hidden) return;
          rows.push(renderLink(item, i));
        });
        return rows;
      })()}
    </nav>
  );
}

function LinkIcon({ name }) {
  const wrap = (children) => (
    <div className="link-icon" aria-hidden="true">{children}</div>
  );
  // A custom uploaded icon is stored as its image URL instead of one of the
  // built-in keys below (see IconPicker in edit.html) — render it as a
  // plain image rather than falling through to the default glyph.
  if (typeof name === "string" && /^(https?:\/\/|\/uploads\/)/.test(name)) {
    return wrap(<img src={name} alt="" loading="lazy" />);
  }
  switch (name) {
    // Real brand marks. Instagram through OpenSea below are the official
    // Simple Icons (CC0) paths. Trovo, DLive, Truth Social and Pump.fun
    // don't have a license-clean vector available anywhere verifiable, so
    // they're hand-built from confirmed design descriptions of the real
    // marks rather than traced from an official source file; Magic Eden
    // stays a generic gem glyph for the same reason — for pixel-exact
    // versions of those four, use the custom image upload in the picker.
    case "ig":
      return wrap(
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M7.0301.084c-1.2768.0602-2.1487.264-2.911.5634-.7888.3075-1.4575.72-2.1228 1.3877-.6652.6677-1.075 1.3368-1.3802 2.127-.2954.7638-.4956 1.6365-.552 2.914-.0564 1.2775-.0689 1.6882-.0626 4.947.0062 3.2586.0206 3.6671.0825 4.9473.061 1.2765.264 2.1482.5635 2.9107.308.7889.72 1.4573 1.388 2.1228.6679.6655 1.3365 1.0743 2.1285 1.38.7632.295 1.6361.4961 2.9134.552 1.2773.056 1.6884.069 4.9462.0627 3.2578-.0062 3.668-.0207 4.9478-.0814 1.28-.0607 2.147-.2652 2.9098-.5633.7889-.3086 1.4578-.72 2.1228-1.3881.665-.6682 1.0745-1.3378 1.3795-2.1284.2957-.7632.4966-1.636.552-2.9124.056-1.2809.0692-1.6898.063-4.948-.0063-3.2583-.021-3.6668-.0817-4.9465-.0607-1.2797-.264-2.1487-.5633-2.9117-.3084-.7889-.72-1.4568-1.3876-2.1228C21.2982 1.33 20.628.9208 19.8378.6165 19.074.321 18.2017.1197 16.9244.0645 15.6471.0093 15.236-.005 11.977.0014 8.718.0076 8.31.0215 7.0301.0839m.1402 21.6932c-1.17-.0509-1.8053-.2453-2.2287-.408-.5606-.216-.96-.4771-1.3819-.895-.422-.4178-.6811-.8186-.9-1.378-.1644-.4234-.3624-1.058-.4171-2.228-.0595-1.2645-.072-1.6442-.079-4.848-.007-3.2037.0053-3.583.0607-4.848.05-1.169.2456-1.805.408-2.2282.216-.5613.4762-.96.895-1.3816.4188-.4217.8184-.6814 1.3783-.9003.423-.1651 1.0575-.3614 2.227-.4171 1.2655-.06 1.6447-.072 4.848-.079 3.2033-.007 3.5835.005 4.8495.0608 1.169.0508 1.8053.2445 2.228.408.5608.216.96.4754 1.3816.895.4217.4194.6816.8176.9005 1.3787.1653.4217.3617 1.056.4169 2.2263.0602 1.2655.0739 1.645.0796 4.848.0058 3.203-.0055 3.5834-.061 4.848-.051 1.17-.245 1.8055-.408 2.2294-.216.5604-.4763.96-.8954 1.3814-.419.4215-.8181.6811-1.3783.9-.4224.1649-1.0577.3617-2.2262.4174-1.2656.0595-1.6448.072-4.8493.079-3.2045.007-3.5825-.006-4.848-.0608M16.953 5.5864A1.44 1.44 0 1 0 18.39 4.144a1.44 1.44 0 0 0-1.437 1.4424M5.8385 12.012c.0067 3.4032 2.7706 6.1557 6.173 6.1493 3.4026-.0065 6.157-2.7701 6.1506-6.1733-.0065-3.4032-2.771-6.1565-6.174-6.1498-3.403.0067-6.156 2.771-6.1496 6.1738M8 12.0077a4 4 0 1 1 4.008 3.9921A3.9996 3.9996 0 0 1 8 12.0077" />
        </svg>
      );
    case "tk":
      return wrap(
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M14 3v10.4a3.6 3.6 0 1 1-3.6-3.6h.6V13a1.6 1.6 0 1 0 1.6 1.6V3h2.7a4.7 4.7 0 0 0 4.7 4.7V10a7 7 0 0 1-6-3.4z" />
        </svg>
      );
    case "yt":
      return wrap(
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z" />
        </svg>
      );
    case "sp":
      return wrap(
        <svg viewBox="0 0 24 24" fill="currentColor">
          <path d="M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.521 17.34c-.24.359-.66.48-1.021.24-2.82-1.74-6.36-2.101-10.561-1.141-.418.122-.779-.179-.899-.539-.12-.421.18-.78.54-.9 4.56-1.021 8.52-.6 11.64 1.32.42.18.479.659.301 1.02zm1.44-3.3c-.301.42-.841.6-1.262.3-3.239-1.98-8.159-2.58-11.939-1.38-.479.12-1.02-.12-1.14-.6-.12-.48.12-1.021.6-1.141C9.6 9.9 15 10.561 18.72 12.84c.361.181.54.78.241 1.2zm.12-3.36C15.24 8.4 8.82 8.16 5.16 9.301c-.6.179-1.2-.181-1.38-.721-.18-.601.18-1.2.72-1.381 4.26-1.26 11.28-1.02 15.721 1.621.539.3.719 1.02.419 1.56-.299.421-1.02.599-1.559.3z" />
        </svg>
      );
    case "bluesky":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M5.202 2.857C7.954 4.922 10.913 9.11 12 11.358c1.087-2.247 4.046-6.436 6.798-8.501C20.783 1.366 24 .213 24 3.883c0 .732-.42 6.156-.667 7.037-.856 3.061-3.978 3.842-6.755 3.37 4.854.826 6.089 3.562 3.422 6.299-5.065 5.196-7.28-1.304-7.847-2.97-.104-.305-.152-.448-.153-.327 0-.121-.05.022-.153.327-.568 1.666-2.782 8.166-7.847 2.97-2.667-2.737-1.432-5.473 3.422-6.3-2.777.473-5.899-.308-6.755-3.369C.42 10.04 0 4.615 0 3.883c0-3.67 3.217-2.517 5.202-1.026" /></svg>);
    case "threads":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.263 11.097c-.03-3.486-1.92-5.586-5.111-5.586-2.13 0-3.922.963-4.863 2.499l2.062 1.438c.535-.843 1.272-1.543 2.628-1.543 1.528 0 2.318.85 2.544 2.431a15 15 0 0 0-2.236-.173c-4.125 0-6.068 1.867-6.068 4.336s1.943 3.99 4.804 3.99c3.139 0 5.013-2.115 5.781-4.735.798.361 1.348 1.204 1.348 2.47 0 3.387-3.907 5.232-7.22 5.232-4.885 0-8.077-3.207-8.077-8.424 0-6.392 4.223-10.487 9.9-10.487 3.808 0 5.69 1.671 6.97 3.914l2.108-1.475C21.44 2.078 18.331 0 13.663 0 6.227 0 1.168 5.277 1.168 12.934c0 7 4.953 11.066 10.856 11.066 4.878 0 9.809-2.846 9.809-7.716 0-2.545-1.46-4.231-3.569-5.187m-6.33 4.855c-1.077 0-2.026-.512-2.026-1.453 0-1.483 1.822-1.934 3.606-1.934.678 0 1.34.045 1.927.173-.422 1.927-1.671 3.215-3.508 3.214Z" /></svg>);
    case "telegram":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z" /></svg>);
    case "signal":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0q-.934 0-1.83.139l.17 1.111a11 11 0 0 1 3.32 0l.172-1.111A12 12 0 0 0 12 0M9.152.34A12 12 0 0 0 5.77 1.742l.584.961a10.8 10.8 0 0 1 3.066-1.27zm5.696 0-.268 1.094a10.8 10.8 0 0 1 3.066 1.27l.584-.962A12 12 0 0 0 14.848.34M12 2.25a9.75 9.75 0 0 0-8.539 14.459c.074.134.1.292.064.441l-1.013 4.338 4.338-1.013a.62.62 0 0 1 .441.064A9.7 9.7 0 0 0 12 21.75c5.385 0 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25m-7.092.068a12 12 0 0 0-2.59 2.59l.909.664a11 11 0 0 1 2.345-2.345zm14.184 0-.664.909a11 11 0 0 1 2.345 2.345l.909-.664a12 12 0 0 0-2.59-2.59M1.742 5.77A12 12 0 0 0 .34 9.152l1.094.268a10.8 10.8 0 0 1 1.269-3.066zm20.516 0-.961.584a10.8 10.8 0 0 1 1.27 3.066l1.093-.268a12 12 0 0 0-1.402-3.383M.138 10.168A12 12 0 0 0 0 12q0 .934.139 1.83l1.111-.17A11 11 0 0 1 1.125 12q0-.848.125-1.66zm23.723.002-1.111.17q.125.812.125 1.66c0 .848-.042 1.12-.125 1.66l1.111.172a12.1 12.1 0 0 0 0-3.662M1.434 14.58l-1.094.268a12 12 0 0 0 .96 2.591l-.265 1.14 1.096.255.36-1.539-.188-.365a10.8 10.8 0 0 1-.87-2.35m21.133 0a10.8 10.8 0 0 1-1.27 3.067l.962.584a12 12 0 0 0 1.402-3.383zm-1.793 3.848a11 11 0 0 1-2.345 2.345l.664.909a12 12 0 0 0 2.59-2.59zm-19.959 1.1L.357 21.48a1.8 1.8 0 0 0 2.162 2.161l1.954-.455-.256-1.095-1.953.455a.675.675 0 0 1-.81-.81l.454-1.954zm16.832 1.769a10.8 10.8 0 0 1-3.066 1.27l.268 1.093a12 12 0 0 0 3.382-1.402zm-10.94.213-1.54.36.256 1.095 1.139-.266c.814.415 1.683.74 2.591.961l.268-1.094a10.8 10.8 0 0 1-2.35-.869zm3.634 1.24-.172 1.111a12.1 12.1 0 0 0 3.662 0l-.17-1.111q-.812.125-1.66.125a11 11 0 0 1-1.66-.125" /></svg>);
    case "whatsapp":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" /></svg>);
    case "facebook":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.101 23.691v-7.98H6.627v-3.667h2.474v-1.58c0-4.085 1.848-5.978 5.858-5.978.401 0 .955.042 1.468.103a8.68 8.68 0 0 1 1.141.195v3.325a8.623 8.623 0 0 0-.653-.036 26.805 26.805 0 0 0-.733-.009c-.707 0-1.259.096-1.675.309a1.686 1.686 0 0 0-.679.622c-.258.42-.374.995-.374 1.752v1.297h3.919l-.386 2.103-.287 1.564h-3.246v8.245C19.396 23.238 24 18.179 24 12.044c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.628 3.874 10.35 9.101 11.647Z" /></svg>);
    case "snapchat":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.206.793c.99 0 4.347.276 5.93 3.821.529 1.193.403 3.219.299 4.847l-.003.06c-.012.18-.022.345-.03.51.075.045.203.09.401.09.3-.016.659-.12 1.033-.301.165-.088.344-.104.464-.104.182 0 .359.029.509.09.45.149.734.479.734.838.015.449-.39.839-1.213 1.168-.089.029-.209.075-.344.119-.45.135-1.139.36-1.333.81-.09.224-.061.524.12.868l.015.015c.06.136 1.526 3.475 4.791 4.014.255.044.435.27.42.509 0 .075-.015.149-.045.225-.24.569-1.273.988-3.146 1.271-.059.091-.12.375-.164.57-.029.179-.074.36-.134.553-.076.271-.27.405-.555.405h-.03c-.135 0-.313-.031-.538-.074-.36-.075-.765-.135-1.273-.135-.3 0-.599.015-.913.074-.6.104-1.123.464-1.723.884-.853.599-1.826 1.288-3.294 1.288-.06 0-.119-.015-.18-.015h-.149c-1.468 0-2.427-.675-3.279-1.288-.599-.42-1.107-.779-1.707-.884-.314-.045-.629-.074-.928-.074-.54 0-.958.089-1.272.149-.211.043-.391.074-.54.074-.374 0-.523-.224-.583-.42-.061-.192-.09-.389-.135-.567-.046-.181-.105-.494-.166-.57-1.918-.222-2.95-.642-3.189-1.226-.031-.063-.052-.15-.055-.225-.015-.243.165-.465.42-.509 3.264-.54 4.73-3.879 4.791-4.02l.016-.029c.18-.345.224-.645.119-.869-.195-.434-.884-.658-1.332-.809-.121-.029-.24-.074-.346-.119-1.107-.435-1.257-.93-1.197-1.273.09-.479.674-.793 1.168-.793.146 0 .27.029.383.074.42.194.789.3 1.104.3.234 0 .384-.06.465-.105l-.046-.569c-.098-1.626-.225-3.651.307-4.837C7.392 1.077 10.739.807 11.727.807l.419-.015h.06z" /></svg>);
    case "reddit":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.373 0 0 5.373 0 12c0 3.314 1.343 6.314 3.515 8.485l-2.286 2.286C.775 23.225 1.097 24 1.738 24H12c6.627 0 12-5.373 12-12S18.627 0 12 0Zm4.388 3.199c1.104 0 1.999.895 1.999 1.999 0 1.105-.895 2-1.999 2-.946 0-1.739-.657-1.947-1.539v.002c-1.147.162-2.032 1.15-2.032 2.341v.007c1.776.067 3.4.567 4.686 1.363.473-.363 1.064-.58 1.707-.58 1.547 0 2.802 1.254 2.802 2.802 0 1.117-.655 2.081-1.601 2.531-.088 3.256-3.637 5.876-7.997 5.876-4.361 0-7.905-2.617-7.998-5.87-.954-.447-1.614-1.415-1.614-2.538 0-1.548 1.255-2.802 2.803-2.802.645 0 1.239.218 1.712.585 1.275-.79 2.881-1.291 4.64-1.365v-.01c0-1.663 1.263-3.034 2.88-3.207.188-.911.993-1.595 1.959-1.595Zm-8.085 8.376c-.784 0-1.459.78-1.506 1.797-.047 1.016.64 1.429 1.426 1.429.786 0 1.371-.369 1.418-1.385.047-1.017-.553-1.841-1.338-1.841Zm7.406 0c-.786 0-1.385.824-1.338 1.841.047 1.017.634 1.385 1.418 1.385.785 0 1.473-.413 1.426-1.429-.046-1.017-.721-1.797-1.506-1.797Zm-3.703 4.013c-.974 0-1.907.048-2.77.135-.147.015-.241.168-.183.305.483 1.154 1.622 1.964 2.953 1.964 1.33 0 2.47-.81 2.953-1.964.057-.137-.037-.29-.184-.305-.863-.087-1.795-.135-2.769-.135Z" /></svg>);
    case "patreon":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M22.957 7.21c-.004-3.064-2.391-5.576-5.191-6.482-3.478-1.125-8.064-.962-11.384.604C2.357 3.231 1.093 7.391 1.046 11.54c-.039 3.411.302 12.396 5.369 12.46 3.765.047 4.326-4.804 6.068-7.141 1.24-1.662 2.836-2.132 4.801-2.618 3.376-.836 5.678-3.501 5.673-7.031Z" /></svg>);
    case "kofi":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M11.351 2.715c-2.7 0-4.986.025-6.83.26C2.078 3.285 0 5.154 0 8.61c0 3.506.182 6.13 1.585 8.493 1.584 2.701 4.233 4.182 7.662 4.182h.83c4.209 0 6.494-2.234 7.637-4a9.5 9.5 0 0 0 1.091-2.338C21.792 14.688 24 12.22 24 9.208v-.415c0-3.247-2.13-5.507-5.792-5.87-1.558-.156-2.65-.208-6.857-.208m0 1.947c4.208 0 5.09.052 6.571.182 2.624.311 4.13 1.584 4.13 4v.39c0 2.156-1.792 3.844-3.87 3.844h-.935l-.156.649c-.208 1.013-.597 1.818-1.039 2.546-.909 1.428-2.545 3.064-5.922 3.064h-.805c-2.571 0-4.831-.883-6.078-3.195-1.09-2-1.298-4.155-1.298-7.506 0-2.181.857-3.402 3.012-3.714 1.533-.233 3.559-.26 6.39-.26m6.547 2.287c-.416 0-.65.234-.65.546v2.935c0 .311.234.545.65.545 1.324 0 2.051-.754 2.051-2s-.727-2.026-2.052-2.026m-10.39.182c-1.818 0-3.013 1.48-3.013 3.142 0 1.533.858 2.857 1.949 3.897.727.701 1.87 1.429 2.649 1.896a1.47 1.47 0 0 0 1.507 0c.78-.467 1.922-1.195 2.623-1.896 1.117-1.039 1.974-2.364 1.974-3.897 0-1.662-1.247-3.142-3.039-3.142-1.065 0-1.792.545-2.338 1.298-.493-.753-1.246-1.298-2.312-1.298" /></svg>);
    case "tumblr":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.563 24c-5.093 0-7.031-3.756-7.031-6.411V9.747H5.116V6.648c3.63-1.313 4.512-4.596 4.71-6.469C9.84.051 9.941 0 9.999 0h3.517v6.114h4.801v3.633h-4.82v7.47c.016 1.001.375 2.371 2.207 2.371h.09c.631-.02 1.486-.205 1.936-.419l1.156 3.425c-.436.636-2.4 1.374-4.156 1.404h-.178l.011.002z" /></svg>);
    case "soundcloud":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M23.999 14.165c-.052 1.796-1.612 3.169-3.4 3.169h-8.18a.68.68 0 0 1-.675-.683V7.862a.747.747 0 0 1 .452-.724s.75-.513 2.333-.513a5.364 5.364 0 0 1 2.763.755 5.433 5.433 0 0 1 2.57 3.54c.282-.08.574-.121.868-.12.884 0 1.73.358 2.347.992s.948 1.49.922 2.373ZM10.721 8.421c.247 2.98.427 5.697 0 8.672a.264.264 0 0 1-.53 0c-.395-2.946-.22-5.718 0-8.672a.264.264 0 0 1 .53 0ZM9.072 9.448c.285 2.659.37 4.986-.006 7.655a.277.277 0 0 1-.55 0c-.331-2.63-.256-5.02 0-7.655a.277.277 0 0 1 .556 0Zm-1.663-.257c.27 2.726.39 5.171 0 7.904a.266.266 0 0 1-.532 0c-.38-2.69-.257-5.21 0-7.904a.266.266 0 0 1 .532 0Zm-1.647.77a26.108 26.108 0 0 1-.008 7.147.272.272 0 0 1-.542 0 27.955 27.955 0 0 1 0-7.147.275.275 0 0 1 .55 0Zm-1.67 1.769c.421 1.865.228 3.5-.029 5.388a.257.257 0 0 1-.514 0c-.21-1.858-.398-3.549 0-5.389a.272.272 0 0 1 .543 0Zm-1.655-.273c.388 1.897.26 3.508-.01 5.412-.026.28-.514.283-.54 0-.244-1.878-.347-3.54-.01-5.412a.283.283 0 0 1 .56 0Zm-1.668.911c.4 1.268.257 2.292-.026 3.572a.257.257 0 0 1-.514 0c-.241-1.262-.354-2.312-.023-3.572a.283.283 0 0 1 .563 0Z" /></svg>);
    case "pinterest":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12.017 0C5.396 0 .029 5.367.029 11.987c0 5.079 3.158 9.417 7.618 11.162-.105-.949-.199-2.403.041-3.439.219-.937 1.406-5.957 1.406-5.957s-.359-.72-.359-1.781c0-1.663.967-2.911 2.168-2.911 1.024 0 1.518.769 1.518 1.688 0 1.029-.653 2.567-.992 3.992-.285 1.193.6 2.165 1.775 2.165 2.128 0 3.768-2.245 3.768-5.487 0-2.861-2.063-4.869-5.008-4.869-3.41 0-5.409 2.562-5.409 5.199 0 1.033.394 2.143.889 2.741.099.12.112.225.085.345-.09.375-.293 1.199-.334 1.363-.053.225-.172.271-.401.165-1.495-.69-2.433-2.878-2.433-4.646 0-3.776 2.748-7.252 7.92-7.252 4.158 0 7.392 2.967 7.392 6.923 0 4.135-2.607 7.462-6.233 7.462-1.214 0-2.354-.629-2.758-1.379l-.749 2.848c-.269 1.045-1.004 2.352-1.498 3.146 1.123.345 2.306.535 3.55.535 6.607 0 11.985-5.365 11.985-11.987C23.97 5.39 18.592.026 11.985.026L12.017 0z" /></svg>);
    case "kick":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M1.333 0h8v5.333H12V2.667h2.667V0h8v8H20v2.667h-2.667v2.666H20V16h2.667v8h-8v-2.667H12v-2.666H9.333V24h-8Z" /></svg>);
    case "rumble":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M14.4528 13.5458c.8064-.6542.9297-1.8381.2756-2.6445a1.8802 1.8802 0 0 0-.2756-.2756 21.2127 21.2127 0 0 0-4.3121-2.776c-1.066-.51-2.256.2-2.4261 1.414a23.5226 23.5226 0 0 0-.14 5.5021c.116 1.23 1.292 1.964 2.372 1.492a19.6285 19.6285 0 0 0 4.5062-2.704v-.008zm6.9322-5.4002c2.0335 2.228 2.0396 5.637.014 7.8723A26.1487 26.1487 0 0 1 8.2946 23.846c-2.6848.6713-5.4168-.914-6.1662-3.5781-1.524-5.2002-1.3-11.0803.17-16.3045.772-2.744 3.3521-4.4661 6.0102-3.832 4.9242 1.174 9.5443 4.196 13.0764 8.0121v.002z" /></svg>);
    case "trovo":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><rect x="4" y="4" width="2.6" height="16" /><path d="M8.2 6.5h6.7l6.9 5.5-6.9 5.5H8.2z" /></svg>);
    case "dlive":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor" fillRule="evenodd"><path d="M5 4H12.5L16.5 6.2L19 9.3V14.7L16.5 17.8L12.5 20H5V4Z M8.2 7.3H12L14 8.8L15.3 10.6V13.4L14 15.2L12 16.7H8.2V7.3Z" /></svg>);
    case "truthsocial":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><rect x="6.2" y="4.5" width="13.6" height="2.8" /><rect x="3.2" y="4.5" width="2.6" height="2.6" /><rect x="11.6" y="4.5" width="2.8" height="15" /><rect x="17.5" y="17.2" width="2.6" height="2.6" /></svg>);
    case "pumpfun":
      return wrap(
        <svg viewBox="0 0 24 24">
          <g transform="rotate(45 12 12)">
            <path fill="currentColor" d="M12 9H7A3 3 0 0 0 4 12A3 3 0 0 0 7 15H12Z" />
            <path fill="none" stroke="currentColor" strokeWidth="1.6" d="M12 9H17A3 3 0 0 1 20 12A3 3 0 0 1 17 15H12Z" />
          </g>
        </svg>
      );
    case "farcaster":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M18.24.24H5.76C2.5789.24 0 2.8188 0 6v12c0 3.1811 2.5789 5.76 5.76 5.76h12.48c3.1812 0 5.76-2.5789 5.76-5.76V6C24 2.8188 21.4212.24 18.24.24m.8155 17.1662v.504c.2868-.0256.5458.1905.5439.479v.5688h-5.1437v-.5688c-.0019-.2885.2576-.5047.5443-.479v-.504c0-.22.1525-.402.358-.458l-.0095-4.3645c-.1589-1.7366-1.6402-3.0979-3.4435-3.0979-1.8038 0-3.2846 1.3613-3.4435 3.0979l-.0096 4.3578c.2276.0424.5318.2083.5395.4648v.504c.2863-.0256.5457.1905.5438.479v.5688H4.3915v-.5688c-.0019-.2885.2575-.5047.5438-.479v-.504c0-.2529.2011-.4548.4536-.4724v-7.895h-.4905L4.2898 7.008l2.6405-.0005V5.0419h9.9495v1.9656h2.8219l-.6091 2.0314h-.4901v7.8949c.2519.0177.453.2195.453.4724" /></svg>);
    case "opensea":
      return wrap(<svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 0C5.374 0 0 5.374 0 12s5.374 12 12 12 12-5.374 12-12S18.629 0 12 0ZM5.92 12.403l.051-.081 3.123-4.884a.107.107 0 0 1 .187.014c.52 1.169.972 2.623.76 3.528-.088.372-.335.876-.614 1.342a2.405 2.405 0 0 1-.117.199.106.106 0 0 1-.09.045H6.013a.106.106 0 0 1-.091-.163zm13.914 1.68a.109.109 0 0 1-.065.101c-.243.103-1.07.485-1.414.962-.878 1.222-1.548 2.97-3.048 2.97H9.053a4.019 4.019 0 0 1-4.013-4.028v-.072c0-.058.048-.106.108-.106h3.485c.07 0 .12.063.115.132-.026.226.017.459.125.67.206.42.636.682 1.099.682h1.726v-1.347H9.99a.11.11 0 0 1-.089-.173l.063-.09c.16-.231.391-.586.621-.992.156-.274.308-.566.43-.86.024-.052.043-.107.065-.16.033-.094.067-.182.091-.269a4.57 4.57 0 0 0 .065-.223c.057-.25.081-.514.081-.787 0-.108-.004-.221-.014-.327-.005-.117-.02-.235-.034-.352a3.415 3.415 0 0 0-.048-.312 6.494 6.494 0 0 0-.098-.468l-.014-.06c-.03-.108-.056-.21-.09-.317a11.824 11.824 0 0 0-.328-.972 5.212 5.212 0 0 0-.142-.355c-.072-.178-.146-.339-.213-.49a3.564 3.564 0 0 1-.094-.197 4.658 4.658 0 0 0-.103-.213c-.024-.053-.053-.104-.072-.152l-.211-.388c-.029-.053.019-.118.077-.101l1.32.357h.01l.173.05.192.054.07.019v-.783c0-.379.302-.686.679-.686a.66.66 0 0 1 .477.202.69.69 0 0 1 .2.484V6.65l.141.039c.01.005.022.01.031.017.034.024.084.062.147.11.05.038.103.086.165.137a10.351 10.351 0 0 1 .574.504c.214.199.454.432.684.691.065.074.127.146.192.226.062.079.132.156.19.232.079.104.16.212.235.324.033.053.074.108.105.161.096.142.178.288.257.435.034.067.067.141.096.213.089.197.159.396.202.598a.65.65 0 0 1 .029.132v.01c.014.057.019.12.024.184a2.057 2.057 0 0 1-.106.874c-.031.084-.06.17-.098.254-.075.17-.161.343-.264.502-.034.06-.075.122-.113.182-.043.063-.089.123-.127.18a3.89 3.89 0 0 1-.173.221c-.053.072-.106.144-.166.209-.081.098-.16.19-.245.278-.048.058-.1.118-.156.17-.052.06-.108.113-.156.161-.084.084-.15.147-.208.202l-.137.122a.102.102 0 0 1-.072.03h-1.051v1.346h1.322c.295 0 .576-.104.804-.298.077-.067.415-.36.816-.802a.094.094 0 0 1 .05-.03l3.65-1.057a.108.108 0 0 1 .138.103z" /></svg>);
    case "magiceden":
      return wrap(<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M7 4h10l4 5-9 11L4 9z" /><path d="M4 9h16M9.5 4L12 9l-2 11M14.5 4L12 9l2 11" /></svg>);
    case "bag":
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <path d="M6 8h12l-.9 12H6.9z" />
          <path d="M9 8V6.4a3 3 0 0 1 6 0V8" />
          <path d="M6.6 11h10.8" />
        </svg>
      );
    case "mail":
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <rect x="3" y="5" width="18" height="14" rx="2" />
          <path d="M3 7l9 6 9-6" />
        </svg>
      );
    case "globe":
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <circle cx="12" cy="12" r="9" />
          <path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" />
        </svg>
      );
    case "music":
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <path d="M9 18V6l12-2v12" />
          <circle cx="6" cy="18" r="3" />
          <circle cx="18" cy="16" r="3" />
        </svg>
      );
    case "video":
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <rect x="3" y="6" width="14" height="12" rx="2" />
          <path d="M17 10l4-2v8l-4-2z" />
        </svg>
      );
    default:
      return wrap(
        <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6"
             strokeLinecap="round" strokeLinejoin="round">
          <path d="M10 14a4 4 0 0 0 5.66 0l3-3a4 4 0 0 0-5.66-5.66l-1.5 1.5" />
          <path d="M14 10a4 4 0 0 0-5.66 0l-3 3a4 4 0 0 0 5.66 5.66l1.5-1.5" />
        </svg>
      );
  }
}

// ── Footer ──────────────────────────────────────────────────────────────────
// Feature flags, fetched once per page load and shared across components
// (currently just the Footer nav).
let _flagsPromise = null;
function usePublicFlags() {
  const [flags, setFlags] = useState({});
  useEffect(() => {
    if (!_flagsPromise) {
      _flagsPromise = fetch("/api/flags")
        .then((r) => r.ok ? r.json() : {})
        .catch(() => ({}));
    }
    let on = true;
    _flagsPromise.then((f) => { if (on) setFlags(f || {}); });
    return () => { on = false; };
  }, []);
  return flags;
}

function Footer({ pseudo }) {
  const t = useI18n();
  // Visible doors to the network surfaces, only when their flag is on.
  const flags = usePublicFlags();
  return (
    <footer className="footer">
      {flags.discover && <>
        <a className="footer-nav" href="/discover">Discover</a>
        <span className="dot-sep">·</span>
      </>}
      {flags.worldcup && <>
        <a className="footer-nav" href="/pronos">Pronos</a>
        <span className="dot-sep">·</span>
      </>}
      {flags.messaging && <>
        <a className="footer-nav" href="/messages">Messages</a>
        <span className="dot-sep">·</span>
      </>}
      <span>© {new Date().getFullYear()} · stanmaxx</span>
      {pseudo && <>
        <span className="dot-sep">·</span>
        <span>@{pseudo}</span>
      </>}
      <span className="dot-sep">·</span>
      <a href="/terms">{t("common.terms", null, "Conditions")}</a>
      <span className="dot-sep">·</span>
      <a href="/privacy">{t("common.privacy", null, "Confidentialité")}</a>
    </footer>
  );
}

// ── Photo placeholders ──────────────────────────────────────────────────────
// Two visual placeholders — primary (portrait), secondary (lifestyle).
// Each is a CSS gradient/SVG composition so the layout is convincing
// without relying on user-supplied imagery.
function PhotoPortrait() {
  return (
    <svg viewBox="0 0 400 400" preserveAspectRatio="xMidYMid slice"
         width="100%" height="100%">
      <defs>
        <linearGradient id="pp-sky" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#FFD580" />
          <stop offset=".55" stopColor="#FF8FAB" />
          <stop offset="1" stopColor="#7B2CBF" />
        </linearGradient>
        <radialGradient id="pp-sun" cx=".7" cy=".25" r=".35">
          <stop offset="0" stopColor="#FFF6D5" stopOpacity=".95" />
          <stop offset="1" stopColor="#FFF6D5" stopOpacity="0" />
        </radialGradient>
        <linearGradient id="pp-water" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#FF6F91" />
          <stop offset="1" stopColor="#3A0CA3" />
        </linearGradient>
      </defs>
      <rect width="400" height="260" fill="url(#pp-sky)" />
      <rect width="400" height="260" fill="url(#pp-sun)" />
      <circle cx="280" cy="105" r="46" fill="#FFE08A" opacity=".9" />
      <rect y="260" width="400" height="140" fill="url(#pp-water)" />
      <rect y="258" width="400" height="3" fill="#fff" opacity=".55" />
      <g fill="#1a0b1f" opacity=".88">
        <ellipse cx="200" cy="395" rx="74" ry="10" opacity=".25" />
        <path d="M200 165 q-22 0 -22 22 q0 16 12 22 q-30 12 -38 50 q-6 32 -2 138 l100 0 q4 -106 -2 -138 q-8 -38 -38 -50 q12 -6 12 -22 q0 -22 -22 -22z" />
      </g>
      <g fill="#0c0612" opacity=".85">
        <path d="M-10 0 q40 30 50 70 q-20 -20 -50 -10z" />
        <path d="M-10 0 q60 8 90 36 q-40 -6 -90 4z" />
      </g>
    </svg>
  );
}

function PhotoSecondary() {
  return (
    <svg viewBox="0 0 400 400" preserveAspectRatio="xMidYMid slice"
         width="100%" height="100%">
      <defs>
        <linearGradient id="ps-bg" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0" stopColor="#FF8A3D" />
          <stop offset="1" stopColor="#FF2D78" />
        </linearGradient>
        <linearGradient id="ps-pool" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0" stopColor="#22D3EE" />
          <stop offset="1" stopColor="#0EA5C9" />
        </linearGradient>
      </defs>
      <rect width="400" height="400" fill="url(#ps-bg)" />
      <rect x="40" y="200" width="320" height="170" rx="14" fill="url(#ps-pool)" />
      <g stroke="#fff" strokeOpacity=".18" strokeWidth="1">
        <line x1="40" y1="240" x2="360" y2="240" />
        <line x1="40" y1="280" x2="360" y2="280" />
        <line x1="40" y1="320" x2="360" y2="320" />
        <line x1="120" y1="200" x2="120" y2="370" />
        <line x1="200" y1="200" x2="200" y2="370" />
        <line x1="280" y1="200" x2="280" y2="370" />
      </g>
      <ellipse cx="240" cy="260" rx="60" ry="6" fill="#fff" opacity=".5" />
      <ellipse cx="220" cy="290" rx="40" ry="3" fill="#fff" opacity=".35" />
      <g transform="translate(110 150)">
        <ellipse cx="0" cy="6" rx="50" ry="14" fill="#000" opacity=".15" />
        <ellipse cx="0" cy="0" rx="50" ry="14" fill="#FFE066" />
        <ellipse cx="0" cy="0" rx="32" ry="9" fill="url(#ps-pool)" />
      </g>
      <g transform="translate(330 70)" fill="#0c0612" opacity=".82">
        <rect x="-3" y="0" width="6" height="120" />
        <path d="M0 0 q-50 -10 -70 -40 q40 0 70 30z" />
        <path d="M0 0 q50 -10 70 -40 q-40 0 -70 30z" />
        <path d="M0 0 q-30 -40 -70 -36 q20 30 70 30z" />
        <path d="M0 0 q30 -40 70 -36 q-20 30 -70 30z" />
      </g>
    </svg>
  );
}

// ── App: full linktree page ─────────────────────────────────────────────────
// `data` is the public profile payload from /api/u/:pseudo (or /api/profile/me).
// `headerSlot` lets the editor inject a "Back to edit" button.
function LinktreeApp({ data, headerSlot, preview }) {
  const [expanded, setExpanded] = useState(false);
  // Per-element overrides. They live on `data.studio`, which is what the
  // editor's preview builds and what the public payload sends — I first read
  // them from `data.style`, a field that exists on neither, so every card
  // silently got nothing while the link rows (which already read the right
  // one) kept working.
  const overrides = (data && data.studio && data.studio.overrides) || null;

  // Apply gradient settings to :root variables.
  useEffect(() => {
    if (!data) return;
    const r = document.documentElement;
    const p = data.palette || ["#FFE066", "#FF8A3D", "#FF3D7F", "#9D2BFF"];
    r.style.setProperty("--c1", p[0] || "#FFE066");
    r.style.setProperty("--c2", p[1] || "#FF8A3D");
    r.style.setProperty("--c3", p[2] || "#FF3D7F");
    r.style.setProperty("--c4", p[3] || "#9D2BFF");
    r.style.setProperty("--grad-angle", (data.gradAngle ?? 180) + "deg");
    r.style.setProperty("--grad-intensity",
      ((data.gradIntensity ?? 100) / 100).toString());
    // Motion is locked to "lively" per product spec.
    r.style.setProperty("--motion-dur", "18s");

    // Optional solid background colour. When the owner has chosen one we fade
    // in the flat-colour layer over the gradient; when it's null we fade it
    // back out so the gradient shows through again. We don't touch the
    // gradient variables here — they keep their values so clearing the colour
    // restores the exact same gradient.
    if (data.bgColor) {
      r.style.setProperty("--bg-solid", data.bgColor);
      r.style.setProperty("--bg-solid-opacity", "1");
    } else {
      r.style.setProperty("--bg-solid", "transparent");
      r.style.setProperty("--bg-solid-opacity", "0");
    }

    // Page theme. "glass" is the historical look; alternate themes restyle
    // the whole page via html[data-theme="…"] rules in glass.css. The accent
    // picks a colour variant within the theme.
    r.setAttribute("data-theme", data.theme || "glass");
    if (data.themeAccent) r.setAttribute("data-accent", data.themeAccent);
    else r.removeAttribute("data-accent");

    // Photo focal point: the photo always covers its frame (object-fit:
    // cover, never any empty space); these only shift which part is shown.
    r.style.setProperty("--photo-pos-x", (data.photoPosX ?? 50) + "%");
    r.style.setProperty("--photo-pos-y", (data.photoPosY ?? 50) + "%");

    // Display-name font style; "auto" defers to the theme's own typography.
    r.setAttribute("data-name-style", data.nameStyle || "auto");
  }, [data]);

  useEffect(() => {
    if (!data) return;
    // Tint the browser chrome (Safari URL bar, Chrome top/bottom) to match the
    // colour at the very top of the page, per theme — so the bars blend in
    // instead of showing a solid orange band. For the default "glass" theme we
    // use the owner's solid background if set, otherwise the top colour of
    // their gradient; alternate themes have a fixed top colour.
    const THEME_CHROME = {
      noir: "#1b1b22",
      paper: "#f7efe2",
      neon: "#14223f",
      y2k: "#b8c6ff",
      pastel: "#fdf3f7",
      nova: "#0a0614",
    };
    const theme = data.theme || "glass";
    // Only FLAT pages get a chrome colour. A theme or an owner-picked
    // background colour paints the whole page one tone, so the html canvas
    // must be that same tone and the browser bars land on it exactly — this
    // is why the "paper" theme's bars have always been invisible.
    const flat = THEME_CHROME[theme] || data.bgColor || null;
    const root = document.documentElement;
    if (flat) root.style.setProperty("--chrome-bg", flat);
    else root.style.removeProperty("--chrome-bg");

    // A theme-color is set ONLY when the page is one flat colour — a theme, or
    // an owner-picked background. Then it matches exactly and the bars vanish.
    //
    // On a gradient page there is no single right answer: the top and bottom
    // edges are different colours, so any fixed value is wrong at one end. We
    // therefore remove the meta entirely and let the browser tint its bars
    // from the page itself, which is what makes them melt on most sites.
    const stale = document.querySelector('meta[name="theme-color"]');
    if (flat) {
      let mtc = stale;
      if (!mtc) {
        mtc = document.createElement("meta");
        mtc.name = "theme-color";
        document.head.appendChild(mtc);
      }
      if (mtc.content !== flat) mtc.content = flat;
    } else if (stale) {
      stale.parentNode.removeChild(stale);
    }
  }, [data]);

  if (!data) {
    return (
      <div className="page">
        <Background />
      </div>
    );
  }

  return (
    <div {...studioProps(data.studio, data)}>
      <Background />
      <CocoLayer cocos={data.cocos} front={false} />
        <main className="stage">
        <Header
          rightSlot={headerSlot}
          shareUrl={`${location.origin}/${data.pseudo}`}
          housePseudo={data.pseudo}
          houseEnabled={!!data.houseEnabled}
        />

        <ProfileCover
          photoUrl={data.photoUrl}
          storyUrl={data.storyUrl}
          bubbleText={data.bubbleText || `@${data.pseudo}`}
          expanded={expanded}
          onToggle={() => setExpanded((v) => !v)}
          bubbleEnabled={data.bubbleEnabled !== false}
        />

        <Identity
          displayName={data.displayName || data.pseudo}
          tagline={data.tagline}
          nameColor={data.nameColor}
        />

        <SocialsRow socials={data.socials} overrides={overrides} />
        {data.pronounsEnabled && data.pronouns ? <PronounsTag text={data.pronouns} overrides={overrides} /> : null}
        {data.supporterEnabled && data.supporterCountry ? <SupporterBadge country={data.supporterCountry} interactive={!preview} overrides={overrides} /> : null}
        {data.wcRank ? <WcTopBadge rank={data.wcRank} interactive={!preview} /> : null}
        {data.wcPredsCount ? <WcPredsBadge pseudo={data.pseudo} count={data.wcPredsCount} interactive={!preview} overrides={overrides} /> : null}
        {data.stanEnabled && data.stanName ? <StanLabel name={data.stanName} overrides={overrides} /> : null}
        {data.zodiacEnabled && data.zodiacSign ? <ZodiacBadge sign={data.zodiacSign} overrides={overrides} /> : null}
        {data.sportsEnabled ? <SportsBadges sports={data.sports} overrides={overrides} /> : null}
        {data.lyricEnabled && data.lyricText ? <LyricLine text={data.lyricText} /> : null}

        <div className="social-row">
          {data.heartsOpen !== false ? (
            <HeartButton
              pseudo={data.pseudo}
              initialCount={data.heartCount}
              interactive={!preview}
            />
          ) : null}
          <FollowButton pseudo={data.pseudo} interactive={!preview} />
          {data.dmOpen ? <MessageButton pseudo={data.pseudo} interactive={!preview} overrides={overrides} /> : null}
        </div>

        {data.liftsEnabled ? <LiftsBadge lifts={data.lifts} unit={data.liftsUnit} label={data.liftsLabel} overrides={overrides} /> : null}

        <EventCard event={data.event} overrides={overrides} />

        {data.businessEnabled && data.business ? <BusinessSection business={data.business} interactive={!preview}  overrides={overrides} /> : null}
        {data.coinEnabled && data.coin ? <CoinSection coin={data.coin} interactive={!preview} /> : null}
        {data.builderEnabled ? <ProjectsSection projects={data.projects} interactive={!preview}  overrides={overrides} /> : null}
        {data.verse ? <VerseSection verse={data.verse}  overrides={overrides} /> : null}

        <Links items={data.links} overrides={data.studio && data.studio.overrides} />

        <EmailCaptureCard sub={data.subscribe} pseudo={data.pseudo} interactive={!preview}  overrides={overrides} />

        {data.walletEnabled && data.wallet ? <WalletSection wallet={data.wallet} interactive={!preview}  overrides={overrides} /> : null}

        {data.engage ? <EngageSection engage={data.engage} pseudo={data.pseudo} interactive={!preview}  overrides={overrides} /> : null}

        {data.circle ? <CircleSection circle={data.circle} pseudo={data.pseudo} interactive={!preview}  overrides={overrides} /> : null}

        <ArticlesSection articles={data.articles} />

        <HouseEntrance pseudo={data.pseudo} enabled={!!data.houseEnabled} overrides={overrides} />

        <StoreEntrance
          pseudo={data.pseudo}
          enabled={!!data.storeEnabled}
          onLinkpage={data.storeOnLinkpage !== false}
          storeName={data.storeName}
          overrides={overrides}
        />

        <Footer pseudo={data.pseudo} />
      </main>
        <CocoLayer cocos={data.cocos} front={true} />
    </div>
  );
}

// View tracking — called from view.html once the public profile data is
// available. Uses sendBeacon when possible so the ping doesn't block. The
// server enforces dedup per (visitor × profile) over 24h so calling this
// on every page load is safe; the counter only goes up once.
function trackView(pseudo) {
  if (!pseudo || typeof pseudo !== "string") return;
  try {
    const body = JSON.stringify({ pseudo });
    if (navigator.sendBeacon) {
      navigator.sendBeacon("/api/track/view",
        new Blob([body], { type: "application/json" }));
    } else {
      fetch("/api/track/view", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body,
        credentials: "same-origin",
        keepalive: true,
      }).catch(() => {});
    }
  } catch { /* tracking is best-effort */ }
}

// Expose for other scripts.
window.Linktree = {
  App: LinktreeApp,
  Background,
  Header,
  Identity,
  EventCard,
  HouseEntrance,
  StoreEntrance,
  Links,
  ProfileCover,
  BubbleSticker,
  HeartButton,
  StanLabel,
  LyricLine,
  LiftsBadge,
  ZodiacBadge,
  ArticlesSection,
  FollowButton,
  PronounsTag,
  SportsBadges,
  SupporterBadge,
  WcTopBadge,
  WcPredsBadge,
  SportIcon,
  SocialsRow,
  SOCIAL_ICONS,
  ProjectsSection,
  CoinSection,
  WalletSection,
  CHAIN_LABELS,
  BusinessSection,
  COUNTRIES,
  countryFlagUrl,
  PhotoPortrait,
  PhotoSecondary,
  LinkIcon,
  EmailCaptureCard,
  studioProps,
  linkOverrideProps,
  cardOverrideProps,
  STUDIO_ATTRS,
  STUDIO_COLORS,
  BUSINESS_CAT_LABELS,
  businessCatLabel,
  businessDirectionsUrl,
  trackView,
};
