aboutsummaryrefslogtreecommitdiffstats
path: root/src/features/hats.js
blob: 22b573ab1a80bf4d9e3671f43279b3445bfc44ab (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
// Hats — cosmetic supporter hats, worn above the avatar everywhere it shows:
// chat rows, DMs, profile pages, notes, search results, the header.
//
// Ownership lives on the names registrar (a kind-0 field would be
// self-asserted — anyone could wear anything). Buying is a plain Lightning
// invoice minted by the registrar and paid headlessly from the Spending
// balance; the registrar grants on settle, and the sats simply stay with
// coinos. That's the whole point: a hat is proof you chipped in.
//
// Display is a batched lookup (`GET /hats?pks=…`) cached alongside nothing
// else — one request covers a whole room, answers persist for a few hours.
// The crown is Adam's and is not for sale.

import { t } from '../i18n.js';
import { fmtSats } from '../format.js';
import { nip98Header } from '../nip98.js';
import { getNetwork } from '../api.js';

const REGISTRAR = 'https://names.coinos.io';
// Hats exist per network: mainnet hats are the real supporter hats; mutinynet
// runs a parallel play-money economy so staging can exercise the whole flow.
const hatNet = () => (getNetwork() === 'mutinynet' ? 'mutinynet' : 'mainnet');
const ADMIN_PK = '98ae4da926c471c23fd12d1ebdd5839ba82917baa618e184e0c9916d93dcf4f7';

// One drawing per hat, tuned to perch on a circle: `w` scales against the
// avatar's width, `b` is where the hat's bottom sits (as % of avatar height,
// from the bottom), `r` gives it the jaunty angle.
const svg = (inner) => `<svg viewBox="0 0 64 44" xmlns="http://www.w3.org/2000/svg">${inner}</svg>`;
const HATS = [
  {
    id: 'beanie', nameKey: 'hatBeanie', sat: 21, w: 92, b: 79, r: -8,
    art: svg(`<circle cx="32" cy="9" r="6" fill="#f3e6c9" stroke="#c9a96e" stroke-width="0.9"/><path d="M29 7 q3 -2 6 0 M29.5 11 q2.5 2 5 0" stroke="#d9c49a" stroke-width="1" fill="none" stroke-linecap="round"/>
      <path d="M11 34 Q11 12 32 12 Q53 12 53 34 Z" fill="#e0554a"/>
      <path d="M20 14.5 Q26 25 25 32 M32 12.5 Q32 23 32 32 M44 14.5 Q38 25 39 32" stroke="#c9443b" stroke-width="2" fill="none"/>
      <rect x="8" y="31" width="48" height="10" rx="5" fill="#c9443b"/>
      <path d="M15 33 V39.5 M23 32.5 V40.5 M31 32.5 V40.5 M39 32.5 V40.5 M47 33 V39.5" stroke="#b23a32" stroke-width="2"/>`),
  },
  {
    id: 'trucker', nameKey: 'hatTrucker', sat: 2100, w: 106, b: 69, r: -6,
    art: svg(`<path d="M9 33 Q9 10 29 10 Q49 10 49 33 Z" fill="#3579c2"/>
      <path d="M17 33 Q17 14 29 14 Q41 14 41 33 Z" fill="#f2f5f8"/>
      <text x="29" y="28" font-size="14" font-weight="700" text-anchor="middle" fill="#f7931a" font-family="system-ui,sans-serif">₿</text>
      <circle cx="29" cy="9.5" r="2.4" fill="#2a5f9c"/>
      <path d="M45 29 Q58 27.2 61.8 31.6 Q63.4 34.6 59 36.4 Q52 37.1 45 37 Z" fill="#2a5f9c"/>
      <rect x="9" y="32" width="39" height="5" rx="2.5" fill="#2a5f9c"/>`),
  },
  {
    id: 'cowboy', nameKey: 'hatCowboy', sat: 21000, w: 122, b: 69, r: -7,
    art: svg(`<path d="M21 26 Q21 5 32 5 Q43 5 43 26 Z" fill="#a97844"/>
      <path d="M32 5.5 Q29.5 14 31 24" stroke="#8a5f33" stroke-width="1.8" fill="none"/>
      <rect x="20" y="20.5" width="24" height="5" rx="2" fill="#6f4a26"/>
      <path d="M6 27 Q5 18.5 11 24.5 L53 24.5 Q59 18.5 58 27 Q57 31.5 47 34.5 Q32 38.5 17 34.5 Q7 31.5 6 27 Z" fill="#a97844"/>
      <path d="M8 28 Q17 32.5 32 32.5 Q47 32.5 56 28" stroke="#8a5f33" stroke-width="1.5" fill="none"/>`),
  },
  {
    id: 'fedora', nameKey: 'hatFedora', sat: 21000, w: 118, b: 71, r: -8,
    art: svg(`<path d="M18 29 L20 11 Q32 6 44 11 L46 29 Z" fill="#4c525b" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>
      <path d="M20 11 Q32 15.5 44 11" stroke="#3a3f47" stroke-width="2" fill="none"/>
      <path d="M17.4 23 L46.6 23 L47 29 L17 29 Z" fill="#23262c"/>
      <path d="M6 31 Q32 25 58 31 Q58 37 32 37.5 Q6 37 6 31 Z" fill="#3a3f47" stroke="rgba(255,255,255,.1)" stroke-width="0.8"/>`),
  },
  {
    id: 'bowler', nameKey: 'hatBowler', sat: 210000, w: 104, b: 71, r: -7,
    art: svg(`<path d="M15 29 Q15 7 32 7 Q49 7 49 29 Z" fill="#2b2e35" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
      <path d="M22 11 Q27 8.5 33 9.5" stroke="#464b55" stroke-width="2" fill="none" stroke-linecap="round"/>
      <path d="M14.6 24 L49.4 24 L49.6 29 L14.4 29 Z" fill="#17191d"/>
      <path d="M7 31 Q7 26.5 13 29 L51 29 Q57 26.5 57 31 Q57 36 32 36 Q7 36 7 31 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
  },
  {
    id: 'top', nameKey: 'hatTop', sat: 210000, w: 96, b: 73, r: -7,
    art: svg(`<path d="M17 5 Q32 1.5 47 5 L45.5 28 L18.5 28 Z" fill="#26282f" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>
      <path d="M18.7 22 L45.3 22 L45.6 28 L18.4 28 Z" fill="#b6382e"/>
      <path d="M7 30.5 Q7 26.5 14 29 L50 29 Q57 26.5 57 30.5 Q57 36 32 36 Q7 36 7 30.5 Z" fill="#17191d" stroke="rgba(255,255,255,.14)" stroke-width="0.8"/>`),
  },
  {
    id: 'wizard', nameKey: 'hatWizard', sat: 2100000, w: 112, b: 83, r: -4, dx: -8,
    art: svg(`<path d="M25 35 Q33 21 36.5 11 Q37.5 5.5 44 3 Q41 9 40 13 Q44 25 50 35 Z" fill="#6d4bb8"/>
      <circle cx="44.5" cy="3.5" r="2.6" fill="#ffd166"/>
      <ellipse cx="37" cy="36" rx="21" ry="5" fill="#59389f"/>
      <path d="M33 19 l1.2 2.6 2.6 1.2 -2.6 1.2 -1.2 2.6 -1.2 -2.6 -2.6 -1.2 2.6 -1.2 Z" fill="#ffd166"/>
      <path d="M41.5 26 l0.9 2 2 0.9 -2 0.9 -0.9 2 -0.9 -2 -2 -0.9 2 -0.9 Z" fill="#ffe08a"/>`),
  },
  {
    // Not for sale: gifted by the operator to beta testers (see the
    // registrar's /hats/grant). Appears in the shop only once owned.
    id: 'construction', nameKey: 'hatConstruction', sat: null, w: 108, b: 71, r: -6,
    art: svg(`<path d="M12 30 Q12 9 30 9 Q48 9 48 30 Z" fill="#ef7f1a"/>
      <path d="M26.5 9.6 h7 q1.6 0 1.6 1.6 v7.8 h-10.2 v-7.8 q0-1.6 1.6-1.6 Z" fill="#ff9d47"/>
      <path d="M18.5 13.5 Q16 20 16 30 M41.5 13.5 Q44 20 44 30" stroke="#cf640c" stroke-width="1.6" fill="none"/>
      <path d="M44 29.2 Q57 27.4 61.5 31.3 Q63.4 33.9 59 35.2 Q52 35.7 44 35.5 Z" fill="#cf640c"/>
      <rect x="9" y="29" width="40" height="6.5" rx="3" fill="#cf640c" stroke="rgba(0,0,0,.1)" stroke-width="0.8"/>`),
  },
  {
    id: 'crown', nameKey: 'hatCrown', sat: null, w: 86, b: 83, r: -7,
    art: svg(`<path d="M10 38 L7 13 L20 25 L32 6 L44 25 L57 13 L54 38 Z" fill="#f2b32a"/>
      <circle cx="7" cy="12" r="3" fill="#ffd97a"/><circle cx="32" cy="6" r="3" fill="#ffd97a"/><circle cx="57" cy="12" r="3" fill="#ffd97a"/>
      <rect x="9" y="34" width="46" height="8" rx="2.5" fill="#d9930f"/>
      <circle cx="18" cy="38" r="2.2" fill="#e04a56"/><circle cx="32" cy="38" r="2.2" fill="#3a7bd5"/><circle cx="46" cy="38" r="2.2" fill="#3fae6a"/>`),
  },
];
const hatById = (id) => HATS.find((x) => x.id === id) || null;
// -58, not -50: every hat rides a couple of pixels left of center —
// the art leans right and dead-center read as drifting off the head.
const posStyle = (hat) => `width:${hat.w}%;bottom:${hat.b}%;transform:translateX(${-58 + (hat.dx || 0)}%) rotate(${hat.r}deg)`;

export function hatsFeature(ctx) {
  const { h, ui, render, wallet, hook, toast, brandHeader } = ctx;

  // ---- who wears what: batched lookup + persistent cache ------------------

  // Serve from cache instantly, but ask the registrar again after a couple
  // of minutes — six hours of staleness meant a hat swapped on one device
  // kept its old look on every other for the rest of the day. One batched
  // request per quiet period is what the /hats endpoint is for.
  const REVALIDATE = 2 * 60_000;
  const worn = new Map(); // pk -> { hat: id|null, t }
  let loaded = false;
  const loadStore = () => {
    if (loaded) return;
    loaded = true;
    try {
      for (const [pk, v] of Object.entries(wallet.loadFeatureState('hats', {}).worn || {}))
        worn.set(pk, v);
    } catch {}
  };
  let saveT = 0;
  const saveStore = () => {
    clearTimeout(saveT);
    saveT = setTimeout(() => {
      try {
        const keep = [...worn.entries()].sort((a, b) => b[1].t - a[1].t).slice(0, 300);
        wallet.saveFeatureState('hats', { worn: Object.fromEntries(keep) });
      } catch {}
    }, 800);
  };

  // ---- boot stamps ---------------------------------------------------------
  // This feature is a DEFERRED chunk — for the beat between first paint and
  // the chunk landing, avatars render bare and hats popped in late. The core
  // bundle can't hold the hat art, so wrapAvatar leaves a STAMP per head
  // (art + percent geometry, size-independent) in localStorage, and the
  // avatar factory replays stamps until this chunk takes over.
  let stamps = null, stampT = 0;
  const stampStore = () => {
    if (stamps === null) { try { stamps = JSON.parse(localStorage.getItem('hat-stamps') || '{}'); } catch { stamps = {}; } }
    return stamps;
  };
  const stamp = (pk, hat) => {
    const s = stampStore();
    if (!hat) {
      // a bare head clears its stamp, or a removed hat would ghost on boot
      if (s[pk]) { delete s[pk]; saveStamps(); }
      return;
    }
    const next = { s: posStyle(hat), a: hat.art, t: Date.now() };
    if (!s[pk] || s[pk].a !== next.a || s[pk].s !== next.s) {
      s[pk] = next;
      const keys = Object.keys(s);
      if (keys.length > 40) {
        keys.sort((a, b) => (s[a].t || 0) - (s[b].t || 0));
        for (const k of keys.slice(0, keys.length - 40)) delete s[k];
      }
      saveStamps();
    } else s[pk].t = Date.now();
  };
  const saveStamps = () => {
    clearTimeout(stampT);
    stampT = setTimeout(() => { try { localStorage.setItem('hat-stamps', JSON.stringify(stamps)); } catch {} }, 500);
  };

  const pending = new Set();
  let fetchT = 0, failedAt = 0;
  const queueFetch = (pk) => {
    if (Date.now() - failedAt < 60_000) return;
    pending.add(pk);
    if (!fetchT) fetchT = setTimeout(flushFetch, 400);
  };
  async function flushFetch() {
    fetchT = 0;
    const pks = [...pending].slice(0, 100);
    pending.clear();
    if (!pks.length) return;
    try {
      const net = hatNet();
      const r = await fetch(`${REGISTRAR}/hats?pks=${pks.join(',')}&net=${net}`);
      const j = await r.json();
      if (!r.ok) throw new Error('hats lookup failed');
      const now = Date.now();
      let changed = false;
      for (const pk of pks) {
        const key = net + ':' + pk;
        const next = (j.hats || {})[pk] || null;
        // a hat coming OFF is a change too — the old check only repainted
        // when a fresh entry had a hat, so removals lingered on screen
        if ((worn.get(key) || {}).hat !== next) changed = true;
        worn.set(key, { hat: next, t: now });
      }
      saveStore();
      if (changed) render();
    } catch {
      failedAt = Date.now();
    }
  }

  // Stale-while-revalidate: answer from cache immediately, refresh behind.
  function hatFor(pk) {
    if (!pk || !/^[0-9a-f]{64}$/.test(pk)) return null;
    loadStore();
    const v = worn.get(hatNet() + ':' + pk);
    if (!v || Date.now() - v.t > REVALIDATE) queueFetch(pk);
    return v ? v.hat : null;
  }

  // ---- registrar calls ----------------------------------------------------

  const withTimeout = (p, ms, what) => Promise.race([
    p, new Promise((_, rej) => setTimeout(() => rej(new Error(`${what} timed out`)), ms)),
  ]);

  // Hats belong to the shown identity (login npub when there is one, else
  // the wallet key) — the same face the hat will sit on.
  async function identity() {
    const id = hook('nostrLoginIdentity');
    if (id) {
      const signer = id.signer || (await hook('nostrLoginResume'));
      return signer ? { pubkey: id.pubkey, signer } : null;
    }
    const pk = wallet.nostrPubkey && wallet.nostrPubkey();
    return pk ? { pubkey: pk, signer: { signEvent: (e) => wallet.nostrSign(e) } } : null;
  }

  // Safari reports a dropped request as the bare "Load failed", and a fresh
  // PWA's first tap is prime territory for exactly one of those. Retry a
  // NETWORK-shaped failure once, transparently (a fresh NIP-98 header each
  // attempt); a real refusal from the registrar never re-posts. Safe for
  // both posts: /hats/invoice mints anew, /hats/claim is idempotent.
  const NETWORKISH = /load failed|failed to fetch|network|timed? ?out/i;
  async function post(path, payload, signer, attempt = 0) {
    const url = `${REGISTRAR}${path}`;
    const body = JSON.stringify(payload);
    try {
      const auth = await withTimeout(nip98Header(signer, url, 'POST', body), 12000, 'signing');
      const r = await withTimeout(fetch(url, {
        method: 'POST', headers: { 'content-type': 'application/json', authorization: auth }, body,
      }), 12000, 'registrar');
      const j = await r.json().catch(() => ({}));
      if (!r.ok || j.error) throw new Error(j.error || `registrar refused (${r.status})`);
      return j;
    } catch (e) {
      if (attempt < 1 && NETWORKISH.test(e.message || '')) return post(path, payload, signer, attempt + 1);
      throw e;
    }
  }

  // ---- the shop -----------------------------------------------------------

  function openShop() {
    ui.hatShop = true;
    ui.hatConfirm = null;
    loadShop();
  }

  function loadShop() {
    ui.hatShopData = null;
    render();
    const me = ctx.shownPubkey();
    if (!me) { ui.hatShopData = { owned: [], equipped: null }; render(); return; }
    const attempt = (retriesLeft) => fetch(`${REGISTRAR}/hats/${me}?net=${hatNet()}`).then((r) => r.json()).then((j) => {
      if (!ui.hatShop) return;
      ui.hatShopData = { owned: j.owned || [], equipped: j.equipped || null, prices: j.prices || null };
      const now = Date.now();
      worn.set(hatNet() + ':' + me, { hat: j.equipped || null, t: now });
      saveStore();
      render();
    }).catch(() => {
      // one quiet retry before showing the offline shop — see post()
      if (retriesLeft > 0) return new Promise((r) => setTimeout(r, 1200)).then(() => attempt(retriesLeft - 1));
      if (ui.hatShop && !ui.hatShopData) { ui.hatShopData = { owned: [], equipped: null, offline: true }; render(); }
    });
    attempt(1);
  }
  const closeShop = () => { ui.hatShop = null; ui.hatShopData = null; ui.hatConfirm = null; render(); };

  async function buy(hat) {
    const price = priceOf(hat);
    if (!['mainnet', 'mutinynet'].includes(getNetwork())) { toast(t('hatMainnetOnly')); return; }
    if (!hook('arkReady')) { toast(t('hatNeedSpending')); return; }
    const spendable = hook('arkSpendableSat') || 0;
    // Routing headroom, honestly sized: the hat invoice lives on the ASP's
    // own node, so the quoted route fee is ~0–2 sats. The old 2% guard
    // demanded 42k sats of phantom headroom on the wizard hat and told a
    // wallet holding 2,133,786 that it couldn't afford 2,100,000.
    if (spendable < price + Math.max(10, Math.ceil(price * 0.002))) {
      toast(t('hatNoFunds', { sats: fmtSats(price) }));
      return;
    }
    ui.hatBuying = hat.id;
    render();
    try {
      const id = await identity();
      if (!id) {
        if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity'));
        ui.hatBuying = null; ui.hatConfirm = null; render();
        return;
      }
      const inv = await post('/hats/invoice', { hat: hat.id, net: hatNet() }, id.signer);
      await hook('arkPayInvoice', inv.invoice, { maxAmountSat: inv.sat });
      const rec = await post('/hats/claim', { paymentHash: inv.paymentHash, hat: hat.id, net: hatNet() }, id.signer);
      ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped };
      worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() });
      saveStore();
      toast(t('hatYours', { name: t(hat.nameKey) }));
    } catch (e) {
      toast(e.message || String(e));
    } finally {
      ui.hatBuying = null;
      ui.hatConfirm = null;
      render();
    }
  }

  async function equip(hatId) {
    ui.hatBuying = hatId || 'none';
    render();
    try {
      const id = await identity();
      if (!id) {
        if (!hook('nostrReconnectPrompt')) toast(t('msgNoIdentity'));
        ui.hatBuying = null; render();
        return;
      }
      const rec = await post('/hats/equip', { hat: hatId, net: hatNet() }, id.signer);
      ui.hatShopData = { ...ui.hatShopData, owned: rec.owned, equipped: rec.equipped };
      worn.set(hatNet() + ':' + id.pubkey, { hat: rec.equipped, t: Date.now() });
      saveStore();
    } catch (e) {
      toast(e.message || String(e));
    } finally {
      ui.hatBuying = null;
      render();
    }
  }

  const priceOf = (hat) => {
    const d = ui.hatShopData;
    return (d && d.prices && d.prices[hat.id]) || hat.sat;
  };

  const demo = (hat) => h('span', { class: 'hat-demo' },
    h('span', { class: 'hat-demo-head' }),
    h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art }));

  function shopRow(hat, d, me) {
    const ownedHere = d && d.owned.includes(hat.id);
    const wearing = d && d.equipped === hat.id;
    const busy = ui.hatBuying === hat.id;
    let action;
    if (hat.id === 'crown' && me !== ADMIN_PK && !ownedHere) {
      action = h('span', { class: 'small faint' }, t('hatCrownTaken'));
    } else if (busy) {
      action = h('span', { class: 'spinner sm' });
    } else if (wearing) {
      action = h('button', { class: 'btn-sm hat-btn wearing', onClick: () => equip(null) }, t('hatWearing'));
    } else if (ownedHere) {
      action = h('button', { class: 'btn-sm hat-btn', onClick: () => equip(hat.id) }, t('hatWear'));
    } else if (ui.hatConfirm === hat.id) {
      action = h('button', { class: 'btn-sm hat-btn confirm', onClick: () => buy(hat) }, t('hatConfirmTap'));
    } else {
      action = h('button', {
        class: 'btn-sm hat-btn', disabled: !!ui.hatBuying,
        onClick: () => { ui.hatConfirm = hat.id; render(); setTimeout(() => { if (ui.hatConfirm === hat.id) { ui.hatConfirm = null; render(); } }, 5000); },
      }, fmtSats(priceOf(hat)) + ' sats');
    }
    return h('div', { class: 'hat-row' },
      demo(hat),
      h('div', { class: 'col grow', style: 'min-width:0;gap:1px' },
        h('div', { class: 'chat-name' }, t(hat.nameKey)),
        ownedHere
          ? h('div', { class: 'muted small' }, t('hatOwned'))
          : hat.id === 'crown' ? h('div', { class: 'muted small' }, t('hatCrownWho')) : null),
      action);
  }

  function shopScreen() {
    const d = ui.hatShopData;
    const me = ctx.shownPubkey();
    return h('div', { class: 'col', style: 'gap:16px' },
      brandHeader(false),
      h('div', { class: 'card col', style: 'gap:12px' },
        h('h3', { style: 'margin:0' }, '🎩 ' + t('hatShopTitle')),
        h('p', { class: 'small muted', style: 'margin:0' }, t('hatShopBlurb')),
        d == null
          ? h('div', { style: 'text-align:center;padding:16px' }, h('span', { class: 'spinner' }))
          : h('div', { class: 'col', style: 'gap:2px' },
              // unpriced hats (the crown, the beta hard hat) are not
              // merchandise — they appear only in the collection of a head
              // that owns them, never as a listing
              ...HATS.filter((hat) => (priceOf(hat) || 0) > 0 || ((d && d.owned) || []).includes(hat.id))
                .map((hat) => shopRow(hat, d, me))),
        d && d.offline ? h('div', { class: 'small faint', style: 'text-align:center' }, t('hatShopOffline')) : null),
      h('button', { class: 'btn-ghost btn-block', onClick: closeShop }, t('back')));
  }

  // -------------------------------------------------------------------------

  return {
    id: 'hats',
    // The avatar factory (and the search rows) offer every avatar up for
    // decoration; a bare head comes back null and is used as-is.
    // Present the moment this chunk lands — the avatar factory stops
    // replaying boot stamps once a real answer (hat or bare) is available.
    hatsReady() { return true; },
    wrapAvatar(pk, node) {
      const hat = hatById(hatFor(pk));
      stamp(pk, hat); // keep the boot stamp current (or clear it)
      if (!hat) return null;
      return h('span', { class: 'hat-wrap' },
        node,
        h('span', { class: 'ava-hat', style: posStyle(hat), html: hat.art }));
    },
    // Own-profile editor entry: the door to the shop.
    hatShopEntry() {
      return h('button', {
        class: 'btn-sm', style: 'margin-top:6px;align-self:flex-start', onClick: openShop,
      }, '🎩 ' + t('hatShopSupport'));
    },
    screenView() {
      if (ui.screen !== 'wallet' || !ui.hatShop) return null;
      // A restored session brings the shop screen back WITHOUT its data
      // (hatShop rides the nav snapshot; the fetch result doesn't) — refetch
      // instead of rendering a collection nobody loaded. undefined = never
      // fetched; null = fetch in flight.
      if (ui.hatShopData === undefined) setTimeout(loadShop, 0);
      return shopScreen();
    },
  };
}