// house-app.jsx — root of the stanmaxx house page.
//
// Two modes:
//   - Owner: viewer is the logged-in owner of the page (or has ?edit=1 on
//     their own URL). They get the edit chrome (add room / floor, edit roof,
//     edit garden, mailbox with delete + reply, wall composer for new free
//     posts) and every change persists via PUT /api/house/me.
//   - Visitor: read-only house + a compose form on the sidebar to leave a
//     letter in the owner's mailbox + the public wall of replies.
//
// We mount on /:pseudo/house. The pseudo is the last path segment; the
// query string carries optional `?edit=1` which forces edit mode when the
// session matches the page owner. The owner always sees /me/house in edit
// mode by default — no need to add ?edit=1 manually.
//
// State shape mirrors what /api/house/me returns:
//   rooms[], layout[][], garden[], + roof/sky/door fields. We map these
//   directly into the props of the House component (which is unchanged
//   from the prototype). Messages live separately because they're paginated
//   from another endpoint; same for wall posts.

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

// Pull the pseudo from the URL. /:pseudo/house — strip /house, strip slashes.
const HOUSE_PSEUDO = (() => {
  const parts = location.pathname.replace(/^\/+|\/+$/g, '').split('/');
  // First segment is the pseudo, second (optional) is "house".
  return parts[0] || '';
})();

// ?edit=1 forces edit mode (we still verify it's the owner before saving).
const FORCE_EDIT = (() => {
  try { return new URLSearchParams(location.search).get('edit') === '1'; }
  catch { return false; }
})();

function HouseApp() {
  // Re-render the whole house app when the visitor switches language.
  useHouseLang();
  // The data we render. Starts null until /api/house/:pseudo answers.
  const [house, setHouse] = useState(null);
  const [profile, setProfile] = useState(null); // pseudo, displayName, palette, gradAngle, gradIntensity
  const [me, setMe] = useState(null);           // current logged-in user (may be null)
  const [loadState, setLoadState] = useState('loading'); // loading | ok | not_found | disabled
  const [wallPosts, setWallPosts] = useState([]);
  const [messages, setMessages] = useState([]);
  const [editMode, setEditMode] = useState(false);
  const [sheet, setSheet] = useState(null);     // { kind, payload? }
  const [saveState, setSaveState] = useState('idle'); // idle | saving | saved | error
  const [composeState, setComposeState] = useState({ status: 'idle', msg: '' }); // visitor-side feedback
  const [flashSide, setFlashSide] = useState(false);
  // A garden photo/link tapped by anyone (owner or visitor). For a photo we
  // show a small popover with the image; for a link we open it in a new tab.
  const [gardenPreview, setGardenPreview] = useState(null);
  // Neighbourhood (friend graph) state. We fetch lazily — only when the
  // user actually opens the sheet — so it doesn't slow the first paint.
  const [friendsData, setFriendsData] = useState(null);
  const [friendsDiscover, setFriendsDiscover] = useState([]);
  const [friendsLoading, setFriendsLoading] = useState(false);
  const [friendsError, setFriendsError] = useState(false);

  // ── Initial load ─────────────────────────────────────────────────────
  // Two reads in parallel: who am I (session), and the public house data.
  // We also need the wall + messages — wall is public, messages require auth.
  useEffect(() => {
    if (!HOUSE_PSEUDO) {
      setLoadState('not_found');
      return;
    }
    let cancelled = false;

    // 1) Session — used to decide owner vs visitor.
    fetch('/api/me', { credentials: 'same-origin' })
      .then((r) => r.json())
      .then((j) => { if (!cancelled) setMe(j?.user || null); })
      .catch(() => {});

    // 2) Public house payload. If it 404s we render the "house disabled"
    //    empty state — which doubles as the owner's onboarding CTA.
    fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO), { credentials: 'same-origin' })
      .then((r) => r.ok ? r.json() : Promise.reject(r.status))
      .then((d) => {
        if (cancelled) return;
        setProfile({
          pseudo: d.pseudo,
          displayName: d.displayName,
          locale: d.locale,
          palette: d.palette,
          gradAngle: d.gradAngle,
          gradIntensity: d.gradIntensity,
        });
        setHouse(d.house);
        setLoadState('ok');
        // Adopt the owner's locale before any text renders. Mirrors view.html.
        if (d.locale && window.i18n) {
          window.i18n.setLang(d.locale, { skipServer: true });
        }
      })
      .catch((st) => {
        if (cancelled) return;
        if (st === 404) setLoadState('disabled');
        else setLoadState('not_found');
      });

    // 3) Public wall.
    fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO) + '/wall', { credentials: 'same-origin' })
      .then((r) => r.ok ? r.json() : Promise.reject(r.status))
      .then((d) => { if (!cancelled) setWallPosts(d.posts || []); })
      .catch(() => {});

    document.title = '@' + HOUSE_PSEUDO + ' · maison · stanmaxx';

    return () => { cancelled = true; };
  }, []);

  // ── Owner detection + mailbox fetch ─────────────────────────────────
  // We treat the user as "owner" when me.pseudo matches the page pseudo,
  // case-insensitive. The mailbox endpoint is auth-gated so we only call
  // it once we know we're authenticated to the right account.
  const isOwner = !!(me && profile &&
    me.pseudo && profile.pseudo &&
    me.pseudo.toLowerCase() === profile.pseudo.toLowerCase());

  useEffect(() => {
    if (!isOwner) return;
    fetch('/api/house/me/messages', { credentials: 'same-origin' })
      .then((r) => r.ok ? r.json() : Promise.reject(r.status))
      .then((d) => setMessages(d.messages || []))
      .catch(() => {});
  }, [isOwner]);

  // "While you were away" recap — fetched once when the owner lands on their
  // own house. The endpoint also stamps "seen now" so it only surfaces the
  // backlog once per return. We only show the card if something actually
  // happened (visits/knocks/letters), so a quiet return stays uncluttered.
  const [recap, setRecap] = useState(null);
  const [recapDismissed, setRecapDismissed] = useState(false);
  useEffect(() => {
    if (!isOwner) return;
    fetch('/api/house/me/recap', { credentials: 'same-origin' })
      .then((r) => r.ok ? r.json() : Promise.reject(r.status))
      .then((d) => setRecap(d))
      .catch(() => {});
  }, [isOwner]);

  // Owner defaults edit mode on if either FORCE_EDIT or there are 0 rooms
  // (newly-seeded state) so onboarding doesn't require the user to find
  // an edit toggle. Otherwise stays off so reads are clean.
  useEffect(() => {
    if (isOwner && (FORCE_EDIT || (house?.rooms?.length === 0))) {
      setEditMode(true);
    }
  }, [isOwner, house]);

  // ── Push owner palette into CSS vars ─────────────────────────────────
  // Same recipe as linktree.jsx: write to :root so the day sky gradient
  // in house.css picks it up. We also write the grad angle + intensity so
  // visitors see the exact gradient the owner picked on their main page.
  useEffect(() => {
    if (!profile) return;
    const r = document.documentElement;
    const p = profile.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', (profile.gradAngle ?? 180) + 'deg');
    r.style.setProperty('--grad-intensity', ((profile.gradIntensity ?? 100) / 100).toString());
  }, [profile]);

  // Tell the page (via a body attribute) whether we're in day or night so
  // house.css can paint the whole document background accordingly. This is
  // what makes night mode cover the ENTIRE screen — sky, the area below the
  // house, and behind the wall — instead of leaving a day-gradient band.
  // The page is "night" when the owner forced it OR when the visitor's real
  // local time is night (matching the in-house sky). We re-check periodically
  // so a long visit that crosses into night updates the page background too.
  useEffect(() => {
    const apply = () => {
      const isNight = (house && house.night)
        || (typeof resolveSkyPhase === 'function' && house && resolveSkyPhase(house.night) === 'night');
      document.body.dataset.houseMode = isNight ? 'night' : 'day';
    };
    apply();
    const id = setInterval(apply, 5 * 60 * 1000);
    return () => { clearInterval(id); delete document.body.dataset.houseMode; };
  }, [house]);

  // ── Mutation helpers (owner only) ────────────────────────────────────
  //
  // Every owner-side change calls setHouseAndSave(nextHouse): it updates
  // local state synchronously (so the change shows on the house instantly)
  // and fires a debounced PUT. The debounce coalesces rapid edits (typing,
  // sliders) into one network write so we never hammer the server.
  //
  // A monotonically increasing sequence number guards the response: we only
  // adopt the server's canonical copy when no newer edit has happened since
  // this save started, so an in-flight save can never clobber what the user
  // is actively changing.
  const saveTimer = useRef(null);
  const saveSeq = useRef(0);
  const setHouseAndSave = useCallback((next) => {
    // Hard guard: only the owner may mutate the house, even locally. A
    // visitor has no editor path to here, but we bail before touching
    // state so there's zero chance of a visitor altering what they see.
    if (!isOwner) return;
    setHouse(next);
    setSaveState('saving');
    const mySeq = ++saveSeq.current;
    if (saveTimer.current) clearTimeout(saveTimer.current);
    saveTimer.current = setTimeout(() => {
      fetch('/api/house/me', {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ ...next, enabled: true }),
      }).then((r) => r.ok ? r.json() : Promise.reject(r.status))
        .then((fresh) => {
          // Adopt the server's normalised copy only if nothing newer was
          // edited while this request was in flight.
          if (mySeq === saveSeq.current) setHouse(fresh);
          setSaveState('saved');
          setTimeout(() => setSaveState((s) => s === 'saved' ? 'idle' : s), 1400);
        })
        .catch(() => {
          setSaveState('error');
          setTimeout(() => setSaveState((s) => s === 'error' ? 'idle' : s), 2000);
        });
    }, 450);
  }, [isOwner]);

  // ── Room operations ──────────────────────────────────────────────────
  //
  // These mirror the prototype's app-2.jsx — same shapes, same algorithms,
  // just plugged into the API-backed setHouse rather than localStorage.
  const updateRoom = (next) => {
    if (!house) return;
    setHouseAndSave({
      ...house,
      rooms: house.rooms.map((r) => r.id === next.id ? next : r),
    });
  };

  const addRoom = (kind, asNewFloor, slot) => {
    if (!house) return;
    const id = kind + '_' + Date.now();
    const meta = ROOM_LIBRARY[kind];
    const newRoom = {
      id, kind, title: '',
      wallColor: meta.wall,
      items: defaultItemsFor(kind),
    };
    const rooms = [...house.rooms, newRoom];
    let layout;
    if (slot) {
      const next = house.layout.map((f) => [...f]);
      while (next.length <= slot.floor) next.push([null, null]);
      while (next[slot.floor].length <= slot.col) next[slot.floor].push(null);
      next[slot.floor][slot.col] = id;
      layout = next;
    } else if (asNewFloor) {
      layout = [[id], ...house.layout];
    } else {
      const lo = house.layout.map((f) => [...f]);
      const idx = lo.findIndex((f) => f.filter(Boolean).length < 2);
      if (idx === -1) {
        layout = [[id], ...lo];
      } else {
        const emptyCol = lo[idx].findIndex((x) => !x);
        if (emptyCol !== -1) lo[idx][emptyCol] = id; else lo[idx].push(id);
        layout = lo;
      }
    }
    setHouseAndSave({ ...house, rooms, layout });
  };

  const deleteRoom = (id) => {
    if (!house) return;
    const layout = house.layout
      .map((f) => f.map((x) => x === id ? null : x))
      .filter((f) => f.some(Boolean));
    setHouseAndSave({
      ...house,
      rooms: house.rooms.filter((r) => r.id !== id),
      layout,
    });
  };

  const moveRoom = (id, dir) => {
    if (!house) return;
    const lo = house.layout.map((f) => [...f]);
    const fi = lo.findIndex((f) => f.includes(id));
    if (fi === -1) return;
    const ci = lo[fi].indexOf(id);
    lo.forEach((f) => { while (f.length < 2) f.push(null); });
    if (dir === 'left' && ci > 0) {
      [lo[fi][ci-1], lo[fi][ci]] = [lo[fi][ci], lo[fi][ci-1]];
    } else if (dir === 'right' && ci < lo[fi].length - 1) {
      [lo[fi][ci+1], lo[fi][ci]] = [lo[fi][ci], lo[fi][ci+1]];
    } else if (dir === 'up' && fi > 0) {
      const target = lo[fi-1][ci];
      lo[fi-1][ci] = id;
      lo[fi][ci] = target;
    } else if (dir === 'down' && fi < lo.length - 1) {
      const target = lo[fi+1][ci];
      lo[fi+1][ci] = id;
      lo[fi][ci] = target;
    }
    const layout = lo.filter((f) => f.some(Boolean));
    setHouseAndSave({ ...house, layout });
  };

  // ── Sheet routing ────────────────────────────────────────────────────
  // Owners get the editor; visitors get a read-only viewer. We keep the two
  // strictly separate so there's no path for a visitor to reach an editor.
  const openRoom = (id) => setSheet({ kind: 'room', payload: id });
  const openRoomViewer = (id) => setSheet({ kind: 'roomview', payload: id });
  const openMailbox = () => {
    // Visitors don't open the mailbox — they leave a letter via the side
    // form. Only owners see the inbox. For visitors, scroll the sidebar
    // into view as a fallback so they understand the mailbox is "go
    // write something on the side".
    if (!isOwner) {
      const side = document.querySelector('.house-side');
      if (side) side.scrollIntoView({ behavior: 'smooth', block: 'start' });
      return;
    }
    setSheet({ kind: 'mailbox' });
  };
  const openAdd = (mode, slot) => setSheet({ kind: 'add', payload: { mode, slot } });
  const openProfile = () => setSheet({ kind: 'profile' });
  const openScene = () => setSheet({ kind: 'scene' });
  const openRoof = () => setSheet({ kind: 'roof' });
  const openGarden = () => setSheet({ kind: 'garden' });
  const openWall = (replyTo = null) => setSheet({ kind: 'wall', payload: replyTo });
  const closeSheet = () => setSheet(null);

  // Tap on a garden photo/link element. Photos open a popover; links open
  // in a new tab. This is the "reveal on tap" behaviour for the garden —
  // no editor panel for visitors, just the content.
  const tapGardenItem = (item) => {
    if (!item) return;
    if (item.kind === 'link') {
      if (item.url) window.open(item.url, '_blank', 'noopener,noreferrer');
      return;
    }
    if (item.kind === 'photo') {
      setGardenPreview(item);
    }
  };

  // ── Neighbourhood (friend graph) ─────────────────────────────────────
  // Loads on first open of the sheet. Owners see /api/house/me/friends
  // (full graph with both pending directions); visitors see the public
  // listing /api/house/:pseudo/friends, which the server gates by the
  // owner's friends_public flag.
  const loadFriends = useCallback(() => {
    setFriendsLoading(true);
    setFriendsError(false);
    if (isOwner) {
      // Owner: my full friend graph + a discover sample in parallel.
      Promise.all([
        fetch('/api/house/me/friends',  { credentials: 'same-origin' }).then(r => r.ok ? r.json() : Promise.reject(r.status)),
        fetch('/api/house/me/discover', { credentials: 'same-origin' }).then(r => r.ok ? r.json() : { houses: [] }),
      ]).then(([fr, dc]) => {
        setFriendsData(fr);
        setFriendsDiscover(dc.houses || []);
        setFriendsLoading(false);
      }).catch(() => {
        setFriendsError(true);
        setFriendsLoading(false);
      });
    } else if (profile?.pseudo) {
      // Visitor: someone else's public friends list.
      fetch('/api/house/' + encodeURIComponent(profile.pseudo) + '/friends',
        { credentials: 'same-origin' })
        .then(r => r.ok ? r.json() : Promise.reject(r.status))
        .then(d => {
          setFriendsData(d);
          setFriendsLoading(false);
        })
        .catch(() => {
          setFriendsError(true);
          setFriendsLoading(false);
        });
    }
  }, [isOwner, profile?.pseudo]);

  const openNeighbours = () => {
    setSheet({ kind: 'neighbours' });
    loadFriends();
  };

  // Refresh just the discover suggestions (the "↻ autres" button).
  const refreshDiscover = () => {
    fetch('/api/house/me/discover', { credentials: 'same-origin' })
      .then(r => r.ok ? r.json() : Promise.reject(r.status))
      .then(d => setFriendsDiscover(d.houses || []))
      .catch(() => {});
  };

  // ── Knock on the door (visitor → owner) ──────────────────────────────
  const [knockState, setKnockState] = useState('idle');
  const knock = async () => {
    if (knockState === 'sent') return;
    setKnockState('sent');
    try {
      await fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO) + '/knock', {
        method: 'POST', credentials: 'same-origin',
      });
    } catch { /* the door already showed its confirmation; failure is silent */ }
  };

  // ── React to a room (visitor → owner) ────────────────────────────────
  // Returns the fresh per-room counts so the RoomViewer can reconcile its
  // optimistic bump with the server's authoritative total.
  const reactToRoom = async (roomId, emoji) => {
    try {
      const r = await fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO)
        + '/rooms/' + encodeURIComponent(roomId) + '/react', {
        method: 'POST', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ emoji }),
      });
      if (!r.ok) return null;
      const d = await r.json();
      return d.reactions || null;
    } catch {
      return null;
    }
  };

  // "Roll the dice" — wander to a random enabled house. Public (works for
  // logged-out visitors too). We exclude the current house so the die never
  // lands you back where you are. On success we navigate to that house.
  const [diceState, setDiceState] = useState({ status: 'idle' });
  const rollDice = async () => {
    setDiceState({ status: 'rolling' });
    try {
      const r = await fetch('/api/house/random?exclude=' + encodeURIComponent(HOUSE_PSEUDO), {
        credentials: 'same-origin',
      });
      if (!r.ok) { setDiceState({ status: 'none' }); setTimeout(() => setDiceState({ status: 'idle' }), 2500); return; }
      const d = await r.json();
      if (d && d.pseudo) {
        window.location.href = '/' + encodeURIComponent(d.pseudo) + '/house';
      } else {
        setDiceState({ status: 'none' });
        setTimeout(() => setDiceState({ status: 'idle' }), 2500);
      }
    } catch {
      setDiceState({ status: 'none' });
      setTimeout(() => setDiceState({ status: 'idle' }), 2500);
    }
  };

  // Friend actions — all four use the same pattern: optimistic-ish UX
  // (reload after success so badges/counts stay consistent) + error
  // surfaces. We keep them small because the server enforces all the
  // important invariants (status transitions, dedup, banned users).
  const sendFriendRequest = async (pseudo) => {
    setFriendsError(false);
    try {
      const r = await fetch('/api/house/me/friends/request', {
        method: 'POST', credentials: 'same-origin',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ pseudo }),
      });
      const j = await r.json().catch(() => ({}));
      if (!r.ok) {
        // The discover tab ignores this return value (it just relies on the
        // generic error banner), but the "ajouter par pseudo" form reads it
        // to show a precise message (pseudo introuvable, maison fermée…).
        setFriendsError(true);
        return { ok: false, error: j.error || 'error' };
      }
      loadFriends();
      return { ok: true, status: j.status || 'outgoing_pending' };
    } catch {
      setFriendsError(true);
      return { ok: false, error: 'network' };
    }
  };
  const acceptFriendRequest = async (pseudo) => {
    setFriendsError(false);
    const r = await fetch('/api/house/me/friends/accept', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ pseudo }),
    });
    if (!r.ok) { setFriendsError(true); return; }
    loadFriends();
  };
  const removeFriend = async (pseudo) => {
    setFriendsError(false);
    const r = await fetch('/api/house/me/friends/' + encodeURIComponent(pseudo), {
      method: 'DELETE', credentials: 'same-origin',
    });
    if (!r.ok) { setFriendsError(true); return; }
    loadFriends();
  };

  // Toggle the visibility of the owner's friend list (PATCH the head).
  // Updates local house state optimistically so the toggle reacts
  // instantly; setHouseAndSave handles the network write.
  const toggleFriendsPublic = (next) => {
    setHouseAndSave({ ...house, friendsPublic: !!next });
  };

  // ── Mailbox actions (owner) ──────────────────────────────────────────
  const markMessageRead = (id) => {
    setMessages((ms) => ms.map((m) => m.id === id ? { ...m, read: true } : m));
    fetch('/api/house/me/messages/' + id + '/read', {
      method: 'POST', credentials: 'same-origin',
    }).catch(() => {});
  };
  const deleteMessage = (id) => {
    setMessages((ms) => ms.filter((m) => m.id !== id));
    fetch('/api/house/me/messages/' + id, {
      method: 'DELETE', credentials: 'same-origin',
    }).catch(() => {});
  };
  // Approve (or pull back) a public-requested message. Optimistically flip the
  // local flag, then refresh the public wall so the change shows immediately.
  const approveMessage = (id, approve) => {
    setMessages((ms) => ms.map((m) => m.id === id ? { ...m, approved: approve } : m));
    fetch('/api/house/me/messages/' + id + '/approve', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'same-origin',
      body: JSON.stringify({ approve }),
    }).then(() => fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO) + '/wall',
        { credentials: 'same-origin' }))
      .then((wr) => wr && wr.ok ? wr.json() : null)
      .then((j) => { if (j) setWallPosts(j.posts || []); })
      .catch(() => {});
  };

  // ── Wall (owner: full CRUD; visitors only read) ──────────────────────
  const postWall = async ({ text, inReplyTo, messageId }) => {
    const r = await fetch('/api/house/me/wall', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'same-origin',
      body: JSON.stringify({ text, inReplyTo, messageId }),
    });
    if (!r.ok) throw new Error('post_failed');
    // Reload the wall to pick up the new id + created_at.
    const wr = await fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO) + '/wall',
      { credentials: 'same-origin' });
    if (wr.ok) {
      const j = await wr.json();
      setWallPosts(j.posts || []);
    }
  };
  const deleteWall = async (id) => {
    if (!confirm(ht('house.wall.delete_confirm', 'Retirer ceci du mur ?'))) return;
    setWallPosts((ws) => ws.filter((w) => w.id !== id));
    const sid = String(id);
    if (sid[0] === 'm') {
      // An approved visitor message — "remove from wall" = unapprove (the
      // letter itself stays in the mailbox).
      const realId = sid.slice(1);
      setMessages((ms) => ms.map((m) => m.id === +realId ? { ...m, approved: false } : m));
      await fetch('/api/house/me/messages/' + realId + '/approve', {
        method: 'POST', headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin', body: JSON.stringify({ approve: false }),
      }).catch(() => {});
    } else {
      // A legacy owner-pinned wall post — delete it.
      const realId = sid[0] === 'w' ? sid.slice(1) : sid;
      await fetch('/api/house/me/wall/' + realId, {
        method: 'DELETE', credentials: 'same-origin',
      }).catch(() => {});
    }
  };

  // ── Visitor: send a letter ───────────────────────────────────────────
  const sendLetter = async ({ from, body, color, visibility }) => {
    setComposeState({ status: 'sending', msg: '' });
    try {
      const r = await fetch('/api/house/' + encodeURIComponent(HOUSE_PSEUDO) + '/message', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'same-origin',
        body: JSON.stringify({ from, body, color, visibility }),
      });
      if (!r.ok) {
        const j = await r.json().catch(() => ({}));
        if (j.error === 'anon_not_allowed') {
          setComposeState({ status: 'error', msg: ht('house.letter.err_anon', 'Il faut un compte stanmaxx pour écrire ici.') });
        } else if (j.error === 'rate_limited') {
          setComposeState({ status: 'error', msg: ht('house.letter.err_rate', 'Trop de messages récents. Réessaie dans un moment.') });
        } else if (j.error === 'empty_body') {
          setComposeState({ status: 'error', msg: ht('house.letter.err_empty', 'Écris d\'abord ton message.') });
        } else if (j.error === 'too_long') {
          setComposeState({ status: 'error', msg: ht('house.letter.err_long', 'C\'est un peu long — 500 caractères max.') });
        } else {
          setComposeState({ status: 'error', msg: ht('house.letter.err_generic', 'Erreur — réessaie.') });
        }
        return false;
      }
      setComposeState({ status: 'sent', msg: visibility === 'public'
        ? ht('house.letter.sent_public', 'Envoyé. Il apparaîtra sur le mur une fois approuvé.')
        : ht('house.letter.sent', 'Lettre envoyée. Merci.') });
      setFlashSide(true);
      setTimeout(() => setFlashSide(false), 1600);
      setTimeout(() => setComposeState((s) => s.status === 'sent' ? { status: 'idle', msg: '' } : s), 3500);
      return true;
    } catch {
      setComposeState({ status: 'error', msg: ht('house.letter.err_generic', 'Erreur — réessaie.') });
      return false;
    }
  };

  // ── Render ───────────────────────────────────────────────────────────

  // Loading state — same glass shell as the rest of the page, just empty
  // inside. We don't show a spinner since the gradient + glass look
  // intentional during the brief load.
  if (loadState === 'loading') {
    return (
      <div className="house-shell">
        <div className="house-stage">
          <TopBar pseudo={HOUSE_PSEUDO} isOwner={false} editMode={false}
            onToggleEdit={() => {}} unread={0} night={false}
            onOpenMailbox={() => {}} canEdit={false}/>
          <div className="house-stage-day" />
        </div>
      </div>
    );
  }

  // Not found / disabled — visitor and owner both land here when the
  // owner hasn't enabled their house yet. We render different copy + CTA
  // depending on who's looking.
  if (loadState !== 'ok' || !house) {
    return (
      <div className="house-shell">
        <div className="house-stage">
          <TopBar pseudo={HOUSE_PSEUDO} isOwner={isOwner} editMode={false}
            onToggleEdit={() => {}} unread={0} night={false}
            onOpenMailbox={() => {}} canEdit={false}/>
          <div className="house-stage-day" />
          <div className="house-empty">
            {isOwner && me?.pseudo?.toLowerCase() === HOUSE_PSEUDO.toLowerCase() ? (
              <>
                <h1>{ht('house.empty.owner_title', 'ta maison n\'est pas encore ouverte')}</h1>
                <p>{ht('house.empty.owner_body', 'Active-la depuis l\'éditeur pour partager un petit coin à toi en plus de tes liens.')}</p>
                <a className="house-empty-cta" href="/edit">
                  {ht('house.empty.open_editor', 'ouvrir l\'éditeur')}
                  <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
                       stroke="currentColor" strokeWidth="2" strokeLinecap="round"
                       strokeLinejoin="round"><path d="M5 3l5 5-5 5"/></svg>
                </a>
              </>
            ) : (
              <>
                <h1>{ht('house.empty.visitor_title', 'pas de maison ici')}</h1>
                <p>{ht('house.empty.visitor_body', '@{pseudo} n\'a pas (encore) ouvert sa maison.', { pseudo: HOUSE_PSEUDO })}</p>
                <a className="house-empty-cta" href={'/' + HOUSE_PSEUDO}>
                  {ht('house.empty.see_profile', 'voir son profil')}
                  <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
                       stroke="currentColor" strokeWidth="2" strokeLinecap="round"
                       strokeLinejoin="round"><path d="M5 3l5 5-5 5"/></svg>
                </a>
              </>
            )}
          </div>
        </div>
      </div>
    );
  }

  const unread = messages.filter((m) => !m.read).length;
  // "dark" drives the full-screen night styling + hides the day gradient. It
  // follows the same rule as the in-house sky: forced by the owner, or the
  // visitor's real local time being night.
  const dark = !!house.night
    || (typeof resolveSkyPhase === 'function' && resolveSkyPhase(house.night) === 'night');
  const room = sheet?.kind === 'room' ? house.rooms.find((r) => r.id === sheet.payload) : null;
  const roomView = sheet?.kind === 'roomview' ? house.rooms.find((r) => r.id === sheet.payload) : null;
  const canEdit = isOwner;

  return (
    <div className={'house-shell ' + (dark ? 'is-night' : '')}>
      {/* Stage column — top bar + cozy house canvas */}
      <div className="house-stage">
        <TopBar
          pseudo={profile.pseudo}
          isOwner={isOwner}
          editMode={editMode}
          unread={unread}
          night={dark}
          canEdit={canEdit}
          onToggleEdit={() => setEditMode((v) => !v)}
          onToggleNight={() => {
            if (!canEdit) return;
            setHouseAndSave({ ...house, night: !house.night });
          }}
          onOpenMailbox={openMailbox}
        />

        {/* "While you were away" — only for the owner, only when something
            new happened, dismissable. A warm welcome-back that gives a reason
            to return. */}
        {isOwner && recap && !recapDismissed
          && (recap.unreadKnocks > 0 || recap.unreadLetters > 0 || recap.visitCount > 0) && (
          <WhileAwayCard recap={recap} onClose={() => setRecapDismissed(true)}
            onOpenMailbox={recap.unreadLetters > 0 ? openMailbox : null}/>
        )}

        {/* Background gradient — only painted during the day; at night the
            cozy starfield inside House owns the background. */}
        {!dark && <div className="house-stage-day" />}

        <div className="house-stage-inner">
          <House
            rooms={house.rooms}
            layout={house.layout}
            roofStyle={house.roofStyle}
            roofColor={house.roofColor}
            roofPattern={house.roofPattern}
            chimney={house.chimney}
            smoke={house.smoke}
            antenna={house.antenna}
            doorColor={house.doorColor}
            houseName={house.houseName || profile.displayName}
            signature={house.signature || profile.pseudo}
            night={dark}
            mailboxCount={unread}
            gardenItems={house.garden}
            editMode={editMode && canEdit}
            onTapRoom={canEdit ? openRoom : openRoomViewer}
            onTapMailbox={openMailbox}
            onAddRoom={canEdit ? ((mode) => openAdd(mode === 'floor' ? 'floor' : 'room')) : undefined}
            onAddToSlot={canEdit ? ((floor, col) => openAdd('slot', { floor, col })) : undefined}
            onDeleteRoom={canEdit ? deleteRoom : undefined}
            onMoveRoom={canEdit ? moveRoom : undefined}
            onEditRoof={canEdit ? openRoof : undefined}
            onEditGarden={canEdit ? openGarden : undefined}
            onTapItem={tapGardenItem}
            onOpenNeighbours={openNeighbours}
            onRollDice={rollDice}
            onKnock={knock}
            canKnock={!isOwner}
            visitCount={house.visitCount || 0}
            scene={house.scene || 'meadow'}
          />

          {/* Owner edit bar — extra controls beyond the chips floating in
              the house body. Mirrors edit.html's savebar position. */}
          {canEdit && editMode && (
            <div className="house-edit-bar">
              <button className="house-edit-pill" onClick={openProfile}>nom &amp; handle</button>
              <button className="house-edit-pill is-primary" onClick={() => openWall(null)}>+ mot au mur</button>
            </div>
          )}
        </div>

        {/* Save state pill — fades in/out next to the bottom edge. */}
        <div className={'house-savestate ' +
          (saveState === 'saving' || saveState === 'saved' || saveState === 'error' ? 'is-visible ' : '') +
          (saveState === 'error' ? 'is-error' : '')}>
          {saveState === 'saving' ? ht('house.save.saving', 'enregistrement…') :
           saveState === 'saved'  ? ht('house.save.saved', 'enregistré') :
           saveState === 'error'  ? ht('house.save.error', 'erreur — réessaie') : ''}
        </div>
      </div>

      {/* Floating glass dock — actions adapt to the role: owners get
          edit / add / settings, visitors get knock / leave a letter. Both
          get neighbourhood; visitors also get the dice to wander. */}
      <FloatingDock items={isOwner ? [
        { key: 'edit', icon: 'edit', label: ht('house.dock.edit', 'éditer'),
          onClick: () => setEditMode((v) => !v), active: editMode },
        { key: 'add', icon: 'add', label: ht('house.dock.add', 'ajouter'),
          onClick: () => openAdd('room', null) },
        { key: 'scene', icon: 'scene', label: ht('house.dock.scene', 'paysage'),
          onClick: openScene },
        { key: 'settings', icon: 'settings', label: ht('house.dock.settings', 'réglages'),
          onClick: openProfile },
        { key: 'neighbours', icon: 'neighbours', label: ht('house.nav.neighbours', 'voisinage'),
          onClick: openNeighbours },
      ] : [
        // Logged-in visitors get a quick way back to their own house.
        ...(me && me.pseudo ? [{
          key: 'home', icon: 'home', label: ht('house.dock.my_house', 'ma maison'),
          onClick: () => { window.location.href = '/' + encodeURIComponent(me.pseudo) + '/house'; },
        }] : []),
        { key: 'knock', icon: 'knock', label: ht('house.dock.knock', 'toquer'),
          onClick: knock, disabled: knockState === 'sent' },
        { key: 'letter', icon: 'letter', label: ht('house.dock.letter', 'laisser un mot'),
          onClick: () => setSheet({ kind: 'compose' }) },
        { key: 'neighbours', icon: 'neighbours', label: ht('house.nav.neighbours', 'voisinage'),
          onClick: openNeighbours },
        { key: 'dice', icon: 'dice', label: ht('house.dice.cta', 'au hasard'),
          onClick: rollDice, spin: diceState.status === 'rolling', disabled: diceState.status === 'rolling' },
      ]}/>

      {/* Sidebar — wall of replies + compose form for visitors */}
      <aside className={'house-side ' + (flashSide ? 'is-flash' : '')}>
        {/* Little path that separates the garden (above) from the wall of
            letters (below). Purely decorative — sets the "you've walked out
            into the yard" scene for the public replies. */}
        <div className="house-road" aria-hidden="true">
          <span className="house-road-sign">
            <svg viewBox="0 0 24 24" width="13" height="13" fill="none"
                 stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <path d="M3 11.5L12 4l9 7.5"/>
              <path d="M5 10.5V20h14v-9.5"/>
            </svg>
            {ht('house.wall.sign', 'le mur')}
          </span>
        </div>
        <div className="house-side-head">
          <div>
            <div className="house-side-title">{ht('house.wall.title', 'mur de @{pseudo}', { pseudo: profile.pseudo })}</div>
            <span className="house-side-sub">{ht('house.wall.subtitle', 'les réponses publiques')}</span>
          </div>
          {canEdit && (
            <button className="house-chip is-active" style={{ height: 32, fontSize: 12 }}
              onClick={() => openWall(null)}>{ht('house.wall.pin_cta', '+ épingler')}</button>
          )}
        </div>

        {wallPosts.length === 0 && (
          <div className="house-side-empty">
            {ht('house.wall.empty_line1', 'pas encore de mots ici.')}<br/>
            {isOwner
              ? ht('house.wall.empty_owner', 'épingle une réponse pour démarrer.')
              : ht('house.wall.empty_visitor', 'sois le premier à laisser une lettre.')}
          </div>
        )}

        {wallPosts.length > 0 && (
          <div className="house-wall-list">
            {wallPosts.map((w) => (
              <div key={w.id} className="house-wall-card">
                <div className="house-wall-card-from">
                  {w.from
                    ? '@' + w.from
                    : (w.inReplyTo ? ht('house.wall.reply_to', 'à {who}', { who: w.inReplyTo }) : '@' + profile.pseudo)}
                </div>
                <div className="house-wall-card-text">{w.text}</div>
                <div className="house-wall-card-time">{formatRelTime(w.createdAt)}</div>
                {canEdit && (
                  <button className="house-wall-card-del"
                    aria-label={ht('common.delete', 'supprimer')}
                    onClick={() => deleteWall(w.id)}>×</button>
                )}
              </div>
            ))}
          </div>
        )}

        {/* Compose — visitors only (owners reply through the mailbox sheet). */}
        {!isOwner && (
          <ComposeForm
            ownerPseudo={profile.pseudo}
            allowAnonymous={house.allowAnonymous}
            isLoggedIn={!!me}
            mePseudo={me?.pseudo}
            composeState={composeState}
            onSend={sendLetter}
          />
        )}
      </aside>

      {/* Sheets. The EDITORS only render for the owner (canEdit) — a
          visitor has no path to them. Visitors get the read-only RoomViewer
          and (if allowed) the letter composer in the sidebar. */}
      <Sheet open={!!room && canEdit} onClose={closeSheet}>
        {room && canEdit && <RoomEditor room={room} onClose={closeSheet} onUpdate={updateRoom}/>}
      </Sheet>
      {/* Read-only room view — for everyone, but only visitors ever trigger
          it (the owner taps open the editor instead). */}
      <Sheet open={!!roomView} onClose={closeSheet}>
        {roomView && <RoomViewer room={roomView} onClose={closeSheet}
          reactions={(house.reactions && house.reactions[roomView.id]) || {}}
          onReact={!isOwner ? reactToRoom : null}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'add' && canEdit} onClose={closeSheet}>
        {canEdit && <AddRoomSheet
          onClose={closeSheet}
          onAdd={(kind, asFloor) => {
            const slot = sheet?.payload?.slot;
            addRoom(kind, asFloor, slot);
          }}
          mode={sheet?.payload?.mode}
          slot={sheet?.payload?.slot}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'mailbox' && canEdit} onClose={closeSheet}>
        {canEdit && <MailboxSheet
          messages={messages}
          onClose={closeSheet}
          onMarkRead={markMessageRead}
          onDelete={deleteMessage}
          onApprove={approveMessage}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'profile' && canEdit} onClose={closeSheet}>
        {canEdit && <ProfileSheet
          houseName={house.houseName}
          signature={house.signature || profile.pseudo}
          roofStyle={house.roofStyle}
          onClose={closeSheet}
          onUpdate={({ houseName, signature }) => {
            setHouseAndSave({ ...house, houseName, signature });
          }}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'roof' && canEdit} onClose={closeSheet}>
        {canEdit && <RoofSheet
          roofStyle={house.roofStyle} roofColor={house.roofColor} roofPattern={house.roofPattern}
          chimney={house.chimney} smoke={house.smoke} antenna={house.antenna} doorColor={house.doorColor}
          onClose={closeSheet}
          onUpdate={(d) => setHouseAndSave({ ...house, ...d })}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'scene' && canEdit} onClose={closeSheet}>
        {canEdit && <SceneSheet
          scene={house.scene || 'meadow'}
          phase={dark ? 'night' : 'day'}
          onClose={closeSheet}
          onUpdate={(scene) => setHouseAndSave({ ...house, scene })}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'garden' && canEdit} onClose={closeSheet}>
        {canEdit && <GardenSheet
          items={house.garden}
          onClose={closeSheet}
          onUpdate={(next) => setHouseAndSave({ ...house, garden: next })}/>}
      </Sheet>
      <Sheet open={sheet?.kind === 'wall' && canEdit} onClose={closeSheet}>
        {canEdit && <WallReplySheet
          replyTo={sheet?.payload || null}
          onClose={closeSheet}
          onSubmit={postWall}/>}
      </Sheet>

      {/* Compose — visitor's "leave a note" sheet. Opens from the dock; wraps
          the same ComposeForm used in the sidebar so there's a single, working
          path to write to the owner. Auto-closes shortly after a successful send. */}
      <Sheet open={sheet?.kind === 'compose' && !isOwner} onClose={closeSheet}>
        {!isOwner && (
          <div style={{ padding: '2px 2px 6px' }}>
            <div style={{ fontFamily: '"Fredoka", system-ui', fontWeight: 700, fontSize: 18,
              color: '#1c0828', marginBottom: 2 }}>
              {ht('house.compose.sheet_title', 'laisser un mot à @{pseudo}', { pseudo: profile.pseudo })}
            </div>
            <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
              color: 'rgba(0,0,0,0.5)', marginBottom: 12 }}>
              {ht('house.compose.sheet_sub', 'une lettre dans sa boîte aux lettres')}
            </div>
            <ComposeForm
              ownerPseudo={profile.pseudo}
              allowAnonymous={house.allowAnonymous}
              isLoggedIn={!!me}
              mePseudo={me?.pseudo}
              composeState={composeState}
              embedded
              onSend={async (payload) => {
                const ok = await sendLetter(payload);
                if (ok) setTimeout(() => closeSheet(), 1400);
                return ok;
              }}
            />
          </div>
        )}
      </Sheet>

      {/* Neighbourhood — open to anyone (owner or visitor). The sheet
          handles the two cases internally based on isOwner; the server
          enforces friendsPublic gating on the visitor read. */}
      <Sheet open={sheet?.kind === 'neighbours'} onClose={closeSheet}>
        <NeighbourhoodSheet
          isOwner={isOwner}
          ownerPseudo={profile?.pseudo}
          friendsPublic={!!house.friendsPublic}
          data={friendsData}
          discover={friendsDiscover}
          loading={friendsLoading}
          error={friendsError}
          onClose={closeSheet}
          onSendRequest={sendFriendRequest}
          onAccept={acceptFriendRequest}
          onRemove={removeFriend}
          onToggleVisibility={toggleFriendsPublic}
          onRefresh={refreshDiscover}/>
      </Sheet>

      {/* Garden photo preview — a light popover (not a sheet) shown when a
          visitor or the owner taps a photo planted in the garden. Links don't
          use this; they open straight in a new tab. */}
      {gardenPreview && gardenPreview.kind === 'photo' && (
        <div
          onClick={() => setGardenPreview(null)}
          style={{
            position: 'fixed', inset: 0, zIndex: 120,
            display: 'flex', alignItems: 'center', justifyContent: 'center',
            background: 'rgba(0,0,0,0.55)', padding: 24,
            WebkitBackdropFilter: 'blur(4px)', backdropFilter: 'blur(4px)',
          }}>
          <div
            onClick={(e) => e.stopPropagation()}
            style={{
              background: '#fff', border: '3px solid #111', borderRadius: 14,
              boxShadow: '6px 6px 0 #111', padding: 10, maxWidth: 'min(92vw, 420px)',
              maxHeight: '82vh', display: 'flex', flexDirection: 'column',
            }}>
            <div style={{
              borderRadius: 8, overflow: 'hidden', background: '#EADFCE',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              minHeight: 120,
            }}>
              {gardenPreview.src
                ? <img src={gardenPreview.src} alt={gardenPreview.caption || ''}
                    style={{ maxWidth: '100%', maxHeight: '64vh', objectFit: 'contain', display: 'block' }}/>
                : <span style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 12,
                    color: 'rgba(0,0,0,0.45)', padding: 40 }}>pas d'image</span>}
            </div>
            {gardenPreview.caption && (
              <div style={{ marginTop: 8, fontFamily: '"Caveat", cursive', fontSize: 22,
                lineHeight: 1.15, color: '#1c0828', textAlign: 'center' }}>
                {gardenPreview.caption}
              </div>
            )}
            <button
              onClick={() => setGardenPreview(null)}
              style={{
                marginTop: 10, appearance: 'none', alignSelf: 'center',
                background: '#111', color: '#FFF8E8', border: '2px solid #111',
                borderRadius: 999, padding: '6px 18px', cursor: 'pointer',
                fontFamily: '"Fredoka", system-ui', fontWeight: 600, fontSize: 13,
                boxShadow: '2px 2px 0 rgba(0,0,0,0.25)',
              }}>
              fermer
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// SceneSheet — landscape picker. A grid of live mini-previews (each is the
// real ImpressionistScene rendered small) so the owner sees exactly what
// they'll get. Tapping a tile saves it. Natural scenes first, then the two
// impressionist fantasies. The preview uses the current phase.
// ─────────────────────────────────────────────────────────────
const SCENE_META = [
  { id: 'meadow',    label: ['Prairie', 'Meadow', 'Pradera', 'Wiese'] },
  { id: 'orchard',   label: ['Verger', 'Orchard', 'Huerto', 'Obstgarten'] },
  { id: 'lilac',     label: ['Lilas', 'Lilac', 'Lilas', 'Flieder'] },
  { id: 'poppy',     label: ['Coquelicots', 'Poppies', 'Amapolas', 'Mohn'] },
  { id: 'grove',     label: ['Sous-bois', 'Grove', 'Soto', 'Hain'] },
  { id: 'enchanted', label: ['Forêt enchantée', 'Enchanted', 'Bosque mágico', 'Zauberwald'] },
  { id: 'bluehour',  label: ['Heure bleue', 'Blue hour', 'Hora azul', 'Blaue Stunde'] },
];
function sceneLabel(id) {
  const m = SCENE_META.find((s) => s.id === id);
  if (!m) return id;
  const lang = (window.i18n && window.i18n.lang && window.i18n.lang()) || 'fr';
  const idx = { fr: 0, en: 1, es: 2, de: 3 }[lang] ?? 0;
  return m.label[idx] || m.label[0];
}
function SceneSheet({ scene, phase, onClose, onUpdate }) {
  useHouseLang();
  return (
    <div style={{ padding: '4px 2px 8px' }}>
      <div style={{ fontFamily: '"Fredoka", system-ui', fontWeight: 700, fontSize: 18,
        color: '#1c0828', marginBottom: 4 }}>{ht('house.scene.title', 'Choisis ton paysage')}</div>
      <div style={{ fontFamily: '"JetBrains Mono", monospace', fontSize: 11,
        color: 'rgba(0,0,0,0.5)', marginBottom: 14 }}>{ht('house.scene.subtitle', 'le décor impressionniste autour de ta maison')}</div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(132px, 1fr))', gap: 12 }}>
        {SCENE_META.map((m) => {
          const active = (scene || 'meadow') === m.id;
          return (
            <button key={m.id} onClick={() => { onUpdate(m.id); }}
              style={{ appearance: 'none', padding: 0, cursor: 'pointer', textAlign: 'left',
                border: active ? '3px solid #1c0828' : '2px solid rgba(74,59,51,0.35)',
                borderRadius: 14, overflow: 'hidden', background: '#fff',
                boxShadow: active ? '0 4px 14px rgba(74,59,51,0.3)' : '0 2px 8px rgba(74,59,51,0.16)',
                transform: active ? 'translateY(-1px)' : 'none', transition: 'all .15s ease' }}>
              <div style={{ position: 'relative', width: '100%', height: 120, overflow: 'hidden',
                background: '#cdd6c0' }}>
                <ImpressionistScene scene={m.id} phase={phase}/>
              </div>
              <div style={{ padding: '7px 9px', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
                <span style={{ fontFamily: '"Fredoka", system-ui', fontWeight: 600, fontSize: 12.5,
                  color: '#1c0828' }}>{sceneLabel(m.id)}</span>
                {active && <span style={{ fontSize: 13, color: '#1c0828' }}>✓</span>}
              </div>
            </button>
          );
        })}
      </div>
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// FloatingDock — a translucent, rounded "glass" bar pinned near the bottom
// of the screen (Apple-style). It lifts the chrome actions OUT of the painted
// scene so they no longer clash with the impressionist look, and bridges the
// painting and the modern rest of the site. The actions ADAPT to the role:
//   • Owner  → edit, add a room, settings (+ neighbourhood)
//   • Visitor→ knock, leave a letter (+ neighbourhood + wander)
// Items are passed in as a list so the parent decides per role.
// ─────────────────────────────────────────────────────────────
const DOCK_ICONS = {
  edit: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><path d="M4 20h4l10-10-4-4L4 16z"/><path d="M14 6l4 4"/></svg>,
  add: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><path d="M12 5v14M5 12h14"/></svg>,
  settings: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><circle cx="12" cy="12" r="3.2"/><path d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M18.4 5.6l-2.1 2.1M7.7 16.3l-2.1 2.1"/></svg>,
  scene: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 16l5-5 4 4 3-3 6 6"/><circle cx="8.5" cy="9.5" r="1.4" fill="#2A2230" stroke="none"/></svg>,
  knock: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><path d="M6 21V8l6-5 6 5v13"/><path d="M9 21v-6h6v6"/><circle cx="15.5" cy="11" r="0.9" fill="#2A2230" stroke="none"/></svg>,
  letter: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><rect x="3" y="6" width="18" height="13" rx="2"/><path d="M3 8l9 6 9-6"/></svg>,
  dice: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><rect x="4" y="4" width="16" height="16" rx="4"/><circle cx="9" cy="9" r="1.3" fill="#2A2230" stroke="none"/><circle cx="15" cy="9" r="1.3" fill="#2A2230" stroke="none"/><circle cx="9" cy="15" r="1.3" fill="#2A2230" stroke="none"/><circle cx="15" cy="15" r="1.3" fill="#2A2230" stroke="none"/></svg>,
  neighbours: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><path d="M4 11l8-6 8 6"/><path d="M6 10v9h12v-9"/><rect x="10" y="13" width="4" height="6"/></svg>,
  home: (s) => <svg viewBox="0 0 24 24" width="22" height="22" {...s}><path d="M4 11l8-7 8 7"/><path d="M6 9.5V20h12V9.5"/><path d="M10 20v-5h4v5"/></svg>,
};

function FloatingDock({ items }) {
  useHouseLang();
  const stroke = { fill: 'none', stroke: '#2A2230', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' };
  const list = (items || []).filter(Boolean);
  if (!list.length) return null;
  return (
    <div style={{
      position: 'fixed', left: '50%', bottom: 'max(14px, env(safe-area-inset-bottom))',
      transform: 'translateX(-50%) translateZ(0)', zIndex: 50,
      display: 'flex', alignItems: 'center', gap: 2,
      padding: '6px 8px',
      // Liquid glass: a soft tinted gradient + lit top edge. The blur is kept
      // moderate (12px) and the layer is isolated with contain/will-change so
      // the browser doesn't re-blur the whole bar every frame the grass sways.
      background: 'linear-gradient(180deg, rgba(255,255,255,0.62), rgba(255,255,255,0.4))',
      WebkitBackdropFilter: 'blur(12px) saturate(1.6)',
      backdropFilter: 'blur(12px) saturate(1.6)',
      border: '1px solid rgba(255,255,255,0.75)',
      borderRadius: 28,
      boxShadow: '0 6px 22px rgba(60,50,55,0.18), inset 0 1px 1px rgba(255,255,255,0.85), inset 0 -1px 3px rgba(120,110,120,0.12)',
      contain: 'paint', willChange: 'transform',
      maxWidth: 'calc(100vw - 24px)',
    }}>
      {/* glossy highlight sweep across the top — the "liquid" sheen */}
      <span aria-hidden="true" style={{ position: 'absolute', inset: 0, borderRadius: 28,
        background: 'linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(255,255,255,0) 42%)',
        pointerEvents: 'none' }}/>
      {list.map((it) => (
        <button key={it.key} onClick={it.onClick} disabled={it.disabled}
          aria-label={it.label}
          style={{
            position: 'relative', appearance: 'none', border: 0,
            background: it.active ? 'rgba(255,224,138,0.55)' : 'transparent',
            borderRadius: 18,
            display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 3,
            padding: '5px 10px', cursor: it.disabled ? 'default' : 'pointer',
            color: '#2A2230', fontFamily: '"Fredoka", system-ui', fontWeight: 600, fontSize: 11,
            opacity: it.disabled ? 0.55 : 1, transition: 'background .15s ease',
          }}>
          <span style={{ display: 'flex', alignItems: 'center', justifyContent: 'center',
            width: 28, height: 28, transition: 'transform .15s ease',
            transform: it.spin ? 'rotate(12deg)' : 'none' }}>
            {(DOCK_ICONS[it.icon] || DOCK_ICONS.neighbours)(stroke)}
          </span>
          <span style={{ whiteSpace: 'nowrap', letterSpacing: 0.2 }}>{it.label}</span>
          {it.badge > 0 && (
            <span style={{ position: 'absolute', top: 1, right: 5, minWidth: 16, height: 16,
              padding: '0 4px', borderRadius: 8, background: '#E15B5B', color: '#fff',
              fontSize: 10, fontWeight: 700, display: 'flex', alignItems: 'center',
              justifyContent: 'center', boxShadow: '0 1px 3px rgba(0,0,0,0.25)' }}>{it.badge}</span>
          )}
        </button>
      ))}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// WhileAwayCard — "pendant ton absence" recap shown to the owner on return.
// A warm, glanceable summary of what happened since they last opened their
// house: new visits, knocks (with a couple of names), and unread letters.
// Dismissable; only rendered when something actually happened.
// ─────────────────────────────────────────────────────────────
function WhileAwayCard({ recap, onClose, onOpenMailbox }) {
  useHouseLang();
  const knocks  = recap.unreadKnocks || 0;
  const letters = recap.unreadLetters || 0;
  const visits  = recap.visitCount || 0;
  // A short, human list of who knocked (named knockers first, then a count
  // of the anonymous ones), kept to a couple of names so it stays a glance.
  const names = (recap.recentKnocks || []).filter((k) => k.isUser && k.name).map((k) => '@' + k.name);
  const namePreview = names.slice(0, 2).join(', ');
  const extraNames = names.length > 2 ? names.length - 2 : 0;

  return (
    <div style={{
      position: 'relative', margin: '8px auto 0', maxWidth: 360,
      background: 'rgba(255,255,255,0.5)', border: '1px solid rgba(255,255,255,0.65)', borderRadius: 20,
      boxShadow: '0 8px 30px rgba(60,50,55,0.2), inset 0 1px 0 rgba(255,255,255,0.6)', padding: '13px 16px',
      WebkitBackdropFilter: 'blur(20px) saturate(1.5)', backdropFilter: 'blur(20px) saturate(1.5)', zIndex: 7,
    }}>
      <button onClick={onClose} aria-label={ht('common.close', 'fermer')}
        style={{ position: 'absolute', top: 9, right: 9, appearance: 'none',
          width: 24, height: 24, borderRadius: '50%', border: '1px solid rgba(74,59,51,0.3)',
          background: 'rgba(255,255,255,0.6)', cursor: 'pointer',
          fontFamily: '"Fredoka", system-ui', fontWeight: 700, fontSize: 13,
          color: '#4A3B33', lineHeight: 1, padding: 0 }}>×</button>

      <div style={{ fontFamily: '"Fredoka", system-ui', fontWeight: 700, fontSize: 15,
        color: '#1c0828', marginBottom: 9 }}>
        {ht('house.recap.title', 'pendant ton absence')}
      </div>

      <div style={{ display: 'flex', flexDirection: 'column', gap: 5,
        fontFamily: '"JetBrains Mono", monospace', fontSize: 11.5, color: '#1c0828', lineHeight: 1.4 }}>
        {visits > 0 && (
          <div>{ht('house.recap.visits', '{n} visite(s) en tout', { n: visits })}</div>
        )}
        {knocks > 0 && (
          <div>{namePreview
            ? (extraNames > 0
                ? ht('house.recap.knocks_named_more', '{names} +{extra} ont toqué', { names: namePreview, extra: extraNames })
                : ht('house.recap.knocks_named', '{names} ont toqué', { names: namePreview }))
            : ht('house.recap.knocks', '{n} toc-toc', { n: knocks })}</div>
        )}
        {letters > 0 && (
          <div>{ht('house.recap.letters', '{n} lettre(s) non lue(s)', { n: letters })}</div>
        )}
      </div>

      {onOpenMailbox && (
        <button onClick={() => { onOpenMailbox(); onClose(); }}
          style={{ marginTop: 11, appearance: 'none', background: 'rgba(255,224,138,0.85)', color: '#1c0828',
            border: '1px solid rgba(74,59,51,0.25)', borderRadius: 12, padding: '6px 14px', cursor: 'pointer',
            fontFamily: '"Fredoka", system-ui', fontWeight: 600, fontSize: 12,
            boxShadow: '0 2px 8px rgba(74,59,51,0.18)' }}>
          {ht('house.recap.open_mailbox', 'ouvrir la boîte aux lettres')}
        </button>
      )}
    </div>
  );
}

// ─────────────────────────────────────────────────────────────
// Top bar — small floating chrome over the stage. Glass + brand chip on
// the left, mode/edit/mailbox chips on the right. Reads the night flag
// so we tighten the glass tint on dark backgrounds.
// ─────────────────────────────────────────────────────────────
// HouseShareButton — reuses the site-wide share button styling (the same
// .header-share / .share-pop glass classes used on the linktree header) so
// sharing a house feels consistent with the rest of stanmaxx. The popover
// previews the house URL and offers copy + native share. Lives in the house
// top bar.
function HouseShareButton({ pseudo }) {
  useHouseLang();
  const [open, setOpen] = React.useState(false);
  const [copied, setCopied] = React.useState(false);
  const [error, setError] = React.useState(false);
  const wrapRef = React.useRef(null);
  const resetRef = React.useRef(null);

  const url = (typeof location !== 'undefined' ? location.origin : '')
    + '/' + pseudo + '/house';

  React.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 {
        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: ht('house.share.title', 'la maison de @{pseudo}', { pseudo }) }); setOpen(false); }
    catch (e) { if (e && e.name !== 'AbortError') flash('error'); }
  };
  const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;

  return (
    <div className="header-share-wrap" ref={wrapRef}>
      <button className="header-share" aria-label={ht('house.share.cta', 'partager ma maison')}
        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">{ht('house.share.cta', 'partager ma maison')}</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>{ht('common.link_copied', 'Lien copié')}</>)
              : error
              ? ht('common.error_retry', '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>{ht('common.copy_link', 'Copier le lien')}</>)}
          </button>
          {canNativeShare && (
            <button type="button" className="share-pop-native" onClick={doNativeShare}>
              {ht('common.share_more', 'Plus d\'options')}
            </button>
          )}
        </div>
      )}
    </div>
  );
}

function TopBar({ pseudo, isOwner, editMode, unread, night, canEdit, onToggleEdit, onToggleNight, onOpenMailbox }) {
  return (
    <header className="house-top">
      <a className="house-brand" href={'/' + pseudo} title="retour au profil">
        <span className="dot"/>
        <span>@{pseudo}<span className="muted"> · maison</span></span>
        <span className="arrow">
          <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
               stroke="currentColor" strokeWidth="2" strokeLinecap="round"
               strokeLinejoin="round"><path d="M10 3l-5 5 5 5"/></svg>
        </span>
      </a>
      <div className="house-top-actions">
        {/* Mailbox chip — owner sees count badge, visitors get a "scroll
            to the side" affordance (we treat the chip as a quick anchor
            to the compose form). */}
        <span className="house-chip-wrap">
          <button className="house-chip is-icon" onClick={onOpenMailbox}
            aria-label={ht('house.mailbox.title', 'boîte aux lettres')}>
            <MailGlyph small/>
          </button>
          {unread > 0 && <span className="house-chip-badge">{unread}</span>}
        </span>
        {canEdit && (
          <button className="house-chip is-icon" onClick={onToggleNight}
            aria-label={night ? ht('house.toggle.day', 'jour') : ht('house.toggle.night', 'nuit')}>
            {night
              ? <svg width="16" height="16" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4" fill="currentColor"/><g stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2v3M12 19v3M2 12h3M19 12h3M5 5l2 2M17 17l2 2M5 19l2-2M17 7l2-2"/></g></svg>
              : <svg width="16" height="16" viewBox="0 0 24 24"><path d="M20 14a8 8 0 11-10-10 6.5 6.5 0 0010 10z" fill="currentColor"/></svg>}
          </button>
        )}
        {canEdit && <HouseShareButton pseudo={pseudo}/>}
        {!isOwner && (
          <a className="house-chip" href={'/' + pseudo}>
            {ht('house.see_links', 'voir les liens')}
          </a>
        )}
      </div>
    </header>
  );
}

function MailGlyph({ small }) {
  const s = small ? 16 : 20;
  return (
    <svg viewBox="0 0 24 24" width={s} height={s} fill="none">
      <rect x="3" y="6" width="18" height="12" rx="1.5" stroke="currentColor" strokeWidth="2"/>
      <path d="M3 7l9 7 9-7" stroke="currentColor" strokeWidth="2" strokeLinejoin="round"/>
    </svg>
  );
}

// ─────────────────────────────────────────────────────────────
// Compose form — visitor side. Lets non-owners drop a letter in the
// mailbox + (if logged in to a different account) signs the letter with
// their pseudo. Anonymous mode requires the owner to opt in; we hide the
// form entirely if they didn't, and show a helpful note instead.
// ─────────────────────────────────────────────────────────────
const COMPOSE_COLORS = ['#E89D5A', '#7BA8D8', '#A95EA0', '#FFD159', '#5E9E51', '#E15B5B'];

function visToggleStyle(active, embedded) {
  return {
    flex: 1, appearance: 'none', cursor: 'pointer',
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center', gap: 6,
    padding: '8px 10px', borderRadius: 10,
    fontFamily: '"Fredoka", system-ui', fontWeight: 600, fontSize: 12.5,
    border: active ? '2px solid #1c0828' : '1px solid ' + (embedded ? 'rgba(74,59,51,0.3)' : 'rgba(255,255,255,0.5)'),
    background: active ? '#FFE08A' : (embedded ? '#fff' : 'rgba(255,255,255,0.18)'),
    color: active ? '#1c0828' : (embedded ? '#4A3B33' : '#fff'),
    textShadow: (!active && !embedded) ? '0 1px 2px rgba(0,0,0,.16)' : 'none',
  };
}

function ComposeForm({ ownerPseudo, allowAnonymous, isLoggedIn, mePseudo, composeState, onSend, embedded }) {
  useHouseLang();
  const [open, setOpen] = useState(!!embedded);
  const [body, setBody] = useState('');
  const [from, setFrom] = useState('');
  const [color, setColor] = useState(COMPOSE_COLORS[0]);
  const [visibility, setVisibility] = useState('private');
  const textareaRef = useRef(null);

  // Prefill the "from" field for logged-in visitors so they don't retype it.
  // We don't allow editing — the server uses the session pseudo regardless.
  useEffect(() => {
    if (isLoggedIn && mePseudo) setFrom(mePseudo);
  }, [isLoggedIn, mePseudo]);

  // Collapse the composer again once a letter has gone through, so the
  // sidebar returns to its compact "leave a letter" button. The success
  // message still shows briefly via composeState before we fold up.
  useEffect(() => {
    if (!embedded && composeState.status === 'sent') {
      const id = setTimeout(() => setOpen(false), 1200);
      return () => clearTimeout(id);
    }
  }, [composeState.status, embedded]);

  // When the composer opens, drop focus straight into the message field so
  // the visitor can start typing without a second tap — important on mobile
  // where every tap counts.
  useEffect(() => {
    if (open && textareaRef.current) {
      textareaRef.current.focus();
    }
  }, [open]);

  // Anonymous letters off + not logged in = read-only sidebar with a hint.
  if (!isLoggedIn && !allowAnonymous) {
    return (
      <div className="house-compose-anon-warn">
        {ht('house.compose.anon_off', '@{pseudo} accepte les lettres seulement depuis un compte stanmaxx.', { pseudo: ownerPseudo })}
        <div style={{ marginTop: 8, display: 'flex', gap: 8 }}>
          <a className="house-chip is-active" style={{ height: 34, fontSize: 12 }} href="/signup">{ht('house.compose.anon_signup', 'créer un compte')}</a>
          <a className="house-chip" style={{ height: 34, fontSize: 12 }} href="/login">{ht('house.compose.signin', 'se connecter')}</a>
        </div>
      </div>
    );
  }

  // Collapsed state — just the trigger button. This keeps the writing area
  // closed by default (especially on mobile, where an always-open textarea
  // eats the whole screen) and only reveals the form when the visitor taps.
  if (!open && !embedded) {
    return (
      <button type="button" className="house-compose-open"
        onClick={() => setOpen(true)}>
        <svg viewBox="0 0 24 24" width="16" height="16" fill="none"
             stroke="currentColor" strokeWidth="1.9"
             strokeLinecap="round" strokeLinejoin="round">
          <rect x="3" y="6" width="18" height="12" rx="1.5"/>
          <path d="M3 7l9 7 9-7"/>
        </svg>
        {ht('house.compose.label', 'laisser une lettre à @{pseudo}', { pseudo: ownerPseudo })}
      </button>
    );
  }

  const canSend = body.trim().length > 0 && (isLoggedIn || from.trim().length > 0);

  const submit = async () => {
    if (!canSend) return;
    const ok = await onSend({
      from: isLoggedIn ? '' : from.trim(),
      body: body.trim(),
      color,
      visibility,
    });
    if (ok) setBody('');
  };

  return (
    <form className={'house-compose' + (embedded ? ' is-embedded' : '')} onSubmit={(e) => { e.preventDefault(); submit(); }}>
      {!embedded && (
        <div className="house-compose-head">
          <label>{ht('house.compose.label', 'laisser une lettre à @{pseudo}', { pseudo: ownerPseudo })}</label>
          <button type="button" className="house-compose-close"
            onClick={() => setOpen(false)} aria-label={ht('common.close', 'fermer')}>
            <svg viewBox="0 0 16 16" width="14" height="14" fill="none"
                 stroke="currentColor" strokeWidth="2" strokeLinecap="round">
              <path d="M4 4l8 8M12 4l-8 8"/>
            </svg>
          </button>
        </div>
      )}

      {!isLoggedIn && (
        <div>
          <input
            type="text"
            placeholder={ht('house.compose.name_placeholder', 'ton prénom ou pseudo')}
            value={from}
            onChange={(e) => setFrom(e.target.value.slice(0, 24))}
            maxLength={24}
          />
        </div>
      )}

      <div>
        <textarea
          ref={textareaRef}
          placeholder={ht('house.compose.body_placeholder', 'écris quelque chose de gentil…')}
          value={body}
          onChange={(e) => setBody(e.target.value.slice(0, 500))}
          maxLength={500}
        />
      </div>

      <div className="house-compose-meta">
        <span>{body.length}/500</span>
        {!isLoggedIn && (
          <div className="house-compose-colors" aria-label={ht('house.compose.bubble_color', 'couleur de la bulle')}>
            {COMPOSE_COLORS.map((c) => (
              <button
                key={c}
                type="button"
                className={'house-compose-color ' + (color === c ? 'is-on' : '')}
                style={{ background: c }}
                onClick={() => setColor(c)}
                aria-label={ht('house.compose.color_aria', 'couleur {c}', { c })}
              />
            ))}
          </div>
        )}
      </div>

      {/* Visibility toggle — private (mailbox only) vs public (proposed for
          the wall, shown after the owner approves). Default private. */}
      <div style={{ display:'flex', flexDirection:'column', gap: 6 }}>
        <div style={{ display:'flex', gap: 6 }}>
          <button type="button" onClick={() => setVisibility('private')}
            className={embedded ? '' : ''}
            style={visToggleStyle(visibility === 'private', embedded)}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
                 strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <rect x="5" y="11" width="14" height="9" rx="2"/><path d="M8 11V8a4 4 0 018 0v3"/>
            </svg>
            {ht('house.compose.vis_private', 'privé')}
          </button>
          <button type="button" onClick={() => setVisibility('public')}
            style={visToggleStyle(visibility === 'public', embedded)}>
            <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
                 strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
              <circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 010 18M12 3a15 15 0 000 18"/>
            </svg>
            {ht('house.compose.vis_public', 'public')}
          </button>
        </div>
        <div style={{ fontFamily:'"JetBrains Mono", monospace', fontSize: 9.5,
          color: embedded ? '#6a5a52' : 'rgba(255,255,255,0.8)', lineHeight: 1.3,
          textShadow: embedded ? 'none' : '0 1px 2px rgba(0,0,0,.14)' }}>
          {visibility === 'public'
            ? ht('house.compose.vis_public_hint', 'visible par tous sur le mur, une fois approuvé par @{pseudo}', { pseudo: ownerPseudo })
            : ht('house.compose.vis_private_hint', 'seul·e @{pseudo} le verra', { pseudo: ownerPseudo })}
        </div>
      </div>

      {composeState.status !== 'idle' && (
        <div className={'house-compose-status is-' + composeState.status}>
          {composeState.msg || (composeState.status === 'sending' ? ht('house.compose.sending', 'envoi…') : '')}
        </div>
      )}

      <button type="submit" className="house-compose-send" disabled={!canSend || composeState.status === 'sending'}>
        {isLoggedIn
          ? ht('house.compose.send_signed', 'envoyer depuis @{pseudo}', { pseudo: mePseudo || 'moi' })
          : ht('house.compose.send', 'envoyer la lettre')}
      </button>

      {!isLoggedIn && (
        <div style={{
          fontFamily: '"JetBrains Mono", monospace', fontSize: 9.5,
          opacity: 0.75, lineHeight: 1.35, textShadow: '0 1px 2px rgba(0,0,0,.14)',
        }}>
          {ht('house.compose.anon_note', 'tu écris en anonyme. @{pseudo} verra ton prénom mais pas d\'identité vérifiée.', { pseudo: ownerPseudo })}
          {' '}<a href="/signup" style={{ color: '#fff', textDecoration: 'underline' }}>{ht('house.compose.anon_signup', 'créer un compte')}</a>
          {' '}{ht('house.compose.anon_tosign', 'pour signer.')}
        </div>
      )}
    </form>
  );
}

// ─────────────────────────────────────────────────────────────
// Time helper (matches the one in house-sheets.jsx for consistency
// between the wall card and the mailbox row).
// ─────────────────────────────────────────────────────────────
function formatRelTime(ts) {
  if (!ts) return '';
  const parsed = new Date(String(ts).replace(' ', 'T') + (String(ts).endsWith('Z') ? '' : 'Z'));
  if (Number.isNaN(parsed.getTime())) return '';
  const diff = Math.max(0, (Date.now() - parsed.getTime()) / 1000);
  if (diff < 60)      return ht('house.time.now', 'à l\'instant');
  if (diff < 3600)    return ht('house.time.min', '{n}min', { n: Math.floor(diff / 60) });
  if (diff < 86400)   return ht('house.time.hour', '{n}h', { n: Math.floor(diff / 3600) });
  if (diff < 604800)  return ht('house.time.day', '{n}j', { n: Math.floor(diff / 86400) });
  return parsed.toLocaleDateString();
}

ReactDOM.createRoot(document.getElementById('root')).render(<HouseApp/>);
