PageSourceSearch

https://www.frenzygo.com/js/broadcastRequests.js?v=23e6951b0332430f4877fa86b6094026fef50386-gu9

js frenzygo.com collected 2026-09-24 14:13:35 UTC 140,340 bytes, 3,055 lines download raw bytes

1(function () {
2  'use strict';
3
4  if (window.__fgBroadcastRequestsLoaded) return;
5  window.__fgBroadcastRequestsLoaded = true;
6
7  function csrfToken() {
8    var meta = document.querySelector('meta[name="csrf-token"]');
9    return meta ? meta.getAttribute('content') : '';
10  }
11
12  function requestJson(url, options) {
13    var opts = options || {};
14    var headers = opts.headers || {};
15    headers.Accept = 'application/json';
16    if (opts.body && !(opts.body instanceof FormData)) {
17      headers['Content-Type'] = 'application/json';
18    }
19    var token = csrfToken();
20    if (token) headers['X-CSRF-Token'] = token;
21    opts.headers = headers;
22    opts.credentials = 'same-origin';
23    return fetch(url, opts).then(function (res) {
24      return res.json().catch(function () {
25        return {};
26      }).then(function (json) {
27        if (!res.ok) {
28          var nestedMessage = json.error && typeof json.error === 'object' ? json.error.message : '';
29          var err = new Error(json.message || nestedMessage || (typeof json.error === 'string' ? json.error : '') || 'Request failed.');
30          err.response = json;
31          throw err;
32        }
33        return json;
34      });
35    });
36  }
37
38  function formObject(form) {
39    var data = {};
40    Array.prototype.forEach.call(form.elements, function (el) {
41      if (!el.name || el.disabled) return;
42      if ((el.type === 'checkbox' || el.type === 'radio') && !el.checked) return;
43      data[el.name] = el.value;
44    });
45    return data;
46  }
47
48  function setBusy(el, busy) {
49    if (!el) return;
50    el.disabled = !!busy;
51    if (el.classList) el.classList.toggle('loading', !!busy);
52  }
53
54  var PAYMENT_INTENT_LOCK_PREFIX = 'broadcast_payment_lock:';
55
56  function escrowConfigFromDom() {
57    var el = document.querySelector('[data-escrow-enabled]');
58    return {
59      enabled: !!(el && el.getAttribute('data-escrow-enabled') === '1'),
60      stripeKey: el ? (el.getAttribute('data-stripe-publishable-key') || '') : '',
61    };
62  }
63
64  function isPaymentIntentLock(id) {
65    return String(id || '').indexOf(PAYMENT_INTENT_LOCK_PREFIX) === 0;
66  }
67
68  function hasConfirmedEscrowHold(request) {
69    return !!(request
70      && request.status === 'hold_authorized'
71      && request.stripePaymentIntentId
72      && !isPaymentIntentLock(request.stripePaymentIntentId));
73  }
74
75  function fetchBroadcastRequest(requestId) {
76    return requestJson('/broadcast-requests/' + requestId, { method: 'GET' }).then(function (json) {
77      return json.broadcastRequest || json;
78    });
79  }
80
81  function pageMessage(message, kind) {
82    var box = document.getElementById('fgBroadcastMessage');
83    if (!box) return toast(message, kind);
84    box.hidden = false;
85    box.className = 'ui message fg-broadcast-message ' + (kind === 'error' ? 'negative' : kind === 'success' ? 'positive' : 'info');
86    box.textContent = message;
87  }
88
89  var fieldLabels = {
90    itemName: 'item',
91    normalizedItemName: 'normalized item',
92    originalOfferedPrice: 'offer price',
93    originalNeededByMinutes: 'needed-by time',
94    fulfillmentType: 'fulfillment type',
95    buyerLocation: 'buyer location',
96    currency: 'currency',
97  };
98
99  function humanList(items) {
100    return (items || []).map(function (item) {
101      return fieldLabels[item] || item;
102    }).join(', ');
103  }
104
105  function renderParseSummary(validation) {
106    var box = document.getElementById('fgBroadcastParseSummary');
107    if (!box) return;
108
109    var missing = validation && validation.missingFields ? validation.missingFields : [];
110    var errors = validation && validation.errors ? validation.errors : [];
111    var safety = validation && validation.safetyFlags ? validation.safetyFlags : [];
112    var lines = [];
113
114    if (missing.length) lines.push('Missing: ' + humanList(missing) + '.');
115    if (safety.length) lines.push('Safety review: ' + humanList(safety) + '.');
116    if (errors.length) lines = lines.concat(errors);
117
118    if (!lines.length) {
119      box.hidden = true;
120      box.textContent = '';
121      return;
122    }
123
124    box.hidden = false;
125    box.className = 'ui message fg-broadcast-message ' + (errors.length || safety.length ? 'warning' : 'info');
126    box.textContent = lines.join(' ');
127  }
128
129  function toast(message, kind, href) {
130    if (!message) return;
131    if (window.Toastify) {
132      window.Toastify({
133        text: message,
134        duration: 5000,
135        gravity: 'top',
136        position: 'right',
137        destination: href || undefined,
138        close: true,
139        style: { background: kind === 'error' ? '#dc2626' : '#0f766e' },
140      }).showToast();
141      return;
142    }
143    var node = document.createElement('a');
144    node.className = 'fg-broadcast-toast';
145    node.textContent = message;
146    node.href = href || '#';
147    node.style.cssText = 'position:fixed;right:16px;bottom:88px;z-index:10060;background:#111827;color:#fff;padding:12px 14px;border-radius:8px;box-shadow:0 12px 30px rgba(15,23,42,.2);max-width:320px;text-decoration:none;';
148    document.body.appendChild(node);
149    window.setTimeout(function () {
150      if (node.parentNode) node.parentNode.removeChild(node);
151    }, 5000);
152  }
153
154  function fillDetails(validation, parsed) {
155    var form = document.getElementById('fgBroadcastDetailsForm');
156    if (!form) return;
157    var value = (validation && validation.value) || parsed || {};
158    function set(name, val) {
159      var el = form.elements[name];
160      if (!el) return;
161      el.value = val === undefined || val === null ? '' : val;
162    }
163    set('itemName', value.itemName);
164    set('normalizedItemName', value.normalizedItemName);
165    set('quantity', value.quantity);
166    set(
167      'originalOfferedPrice',
168      value.originalOfferedPrice !== undefined ? value.originalOfferedPrice : value.offeredPrice
169    );
170    set(
171      'originalNeededByMinutes',
172      value.originalNeededByMinutes !== undefined ? value.originalNeededByMinutes : value.neededByMinutes
173    );
174    set('fulfillmentType', value.fulfillmentType);
175    set('radiusKm', value.radiusKm);
176    set('currency', value.currency);
177  }
178
179  function createRequest() {
180    var form = document.getElementById('fgBroadcastDetailsForm');
181    if (!form) return Promise.reject(new Error('Missing request details.'));
182    return requestJson('/broadcast-requests', {
183      method: 'POST',
184      body: JSON.stringify(formObject(form)),
185    }).then(function (json) {
186      return json.broadcastRequest;
187    });
188  }
189
190  function initNewPage() {
191    var root = document.querySelector('[data-broadcast-page="new"]');
192    if (!root) return;
193
194    var parseBtn = document.getElementById('fgBroadcastParseBtn');
195    var saveBtn = document.getElementById('fgBroadcastSaveBtn');
196    var sendBtn = document.getElementById('fgBroadcastSendBtn');
197    var confirm = document.getElementById('fgBroadcastConfirm');
198    var createdId = null;
199
200    if (parseBtn) {
201      parseBtn.addEventListener('click', function () {
202        var textarea = document.getElementById('broadcastUserText');
203        var userText = textarea ? textarea.value.trim() : '';
204        if (!userText) return pageMessage('Enter what you need first.', 'error');
205        setBusy(parseBtn, true);
206        requestJson('/broadcast-requests/parse', {
207          method: 'POST',
208          body: JSON.stringify({ userText: userText }),
209        }).then(function (json) {
210          fillDetails(json.validation, json.parsed);
211          renderParseSummary(json.validation);
212          if (confirm) confirm.hidden = false;
213          var missing = json.validation && json.validation.missingFields && json.validation.missingFields.length;
214          pageMessage(missing ? 'Review the missing fields before broadcasting.' : 'Review and edit the details, then broadcast.', missing ? 'info' : 'success');
215        }).catch(function (err) {
216          renderParseSummary(null);
217          pageMessage(err.message, 'error');
218        }).finally(function () {
219          setBusy(parseBtn, false);
220        });
221      });
222    }
223
224    if (saveBtn) {
225      saveBtn.addEventListener('click', function () {
226        setBusy(saveBtn, true);
227        createRequest().then(function (request) {
228          createdId = request && request._id;
229          pageMessage('Draft saved. You can broadcast it now.', 'success');
230        }).catch(function (err) {
231          pageMessage(err.message, 'error');
232        }).finally(function () {
233          setBusy(saveBtn, false);
234        });
235      });
236    }
237
238    if (sendBtn) {
239      sendBtn.addEventListener('click', function () {
240        setBusy(sendBtn, true);
241        var ensureRequest = createdId ? Promise.resolve({ _id: createdId }) : createRequest();
242        ensureRequest.then(function (request) {
243          createdId = request && request._id;
244          if (!createdId) throw new Error('Could not create broadcast request.');
245          // Phase 32: with escrow on, add card on the request page before broadcast.
246          if (escrowConfigFromDom().enabled) {
247            window.location.href = '/broadcast-requests/' + createdId;
248            return null;
249          }
250          return requestJson('/broadcast-requests/' + createdId + '/broadcast', { method: 'POST' });
251        }).then(function (json) {
252          if (!json) return;
253          if (json.availabilityBlocked) {
254            pageMessage(json.message || json.assistantMessage || 'No one nearby is accepting community asks right now.', 'info');
255            return;
256          }
257          var id = json.broadcastRequest && json.broadcastRequest._id ? json.broadcastRequest._id : createdId;
258          window.location.href = '/broadcast-requests/' + id;
259        }).catch(function (err) {
260          var code = err && err.response && err.response.error && err.response.error.code;
261          if (code === 'BROADCAST_HOLD_REQUIRED' && createdId) {
262            window.location.href = '/broadcast-requests/' + createdId;
263            return;
264          }
265          pageMessage(err.message, 'error');
266        }).finally(function () {
267          setBusy(sendBtn, false);
268        });
269      });
270    }
271  }
272
273  var DRAFT_LABELS = {
274    itemName: 'Item',
275    quantity: 'Quantity',
276    originalOfferedPrice: 'Offer price',
277    currency: 'Currency',
278    originalNeededByMinutes: 'Needed in (min)',
279    fulfillmentType: 'Fulfillment',
280    radiusKm: 'Radius (km)',
281    notes: 'Notes',
282  };
283  var DRAFT_ORDER = ['itemName', 'quantity', 'originalOfferedPrice', 'currency', 'originalNeededByMinutes', 'fulfillmentType', 'radiusKm', 'notes'];
284
285  function initBroadcastModes() {
286    // The visible chat/form tabs were removed for the WhatsApp-style chat, but the
287    // mode switch still drives the quick-form fallback (used when the AI assistant
288    // is unavailable). Work whether or not the tab buttons exist.
289    var chatBtn = document.getElementById('fgModeChatBtn');
290    var formBtn = document.getElementById('fgModeFormBtn');
291
292    function setMode(mode) {
293      Array.prototype.forEach.call(document.querySelectorAll('[data-broadcast-mode]'), function (el) {
294        el.hidden = el.getAttribute('data-broadcast-mode') !== mode;
295      });
296      if (chatBtn) {
297        chatBtn.classList.toggle('active', mode === 'chat');
298        chatBtn.setAttribute('aria-pressed', mode === 'chat' ? 'true' : 'false');
299      }
300      if (formBtn) {
301        formBtn.classList.toggle('active', mode === 'form');
302        formBtn.setAttribute('aria-pressed', mode === 'form' ? 'true' : 'false');
303      }
304    }
305
306    if (chatBtn) chatBtn.addEventListener('click', function () { setMode('chat'); });
307    if (formBtn) formBtn.addEventListener('click', function () { setMode('form'); });
308    setMode('chat');
309    return setMode;
310  }
311
312  function prefillFormFromDraft(draft) {
313    var form = document.getElementById('fgBroadcastDetailsForm');
314    if (!form || !draft) return;
315    function set(name, val) {
316      var el = form.elements[name];
317      if (el) el.value = val === undefined || val === null ? '' : val;
318    }
319    set('itemName', draft.itemName);
320    set('normalizedItemName', draft.normalizedItemName);
321    set('quantity', draft.quantity);
322    set('originalOfferedPrice', draft.originalOfferedPrice);
323    set('originalNeededByMinutes', draft.originalNeededByMinutes);
324    set('fulfillmentType', draft.fulfillmentType);
325    set('radiusKm', draft.radiusKm);
326    if (draft.currency) set('currency', draft.currency);
327    var confirm = document.getElementById('fgBroadcastConfirm');
328    if (confirm) confirm.hidden = false;
329  }
330
331  function initChatAssistant(setMode) {
332    var root = document.getElementById('fgBroadcastChat');
333    if (!root) return;
334
335    var log = document.getElementById('fgChatLog');
336    var form = document.getElementById('fgChatForm');
337    var input = document.getElementById('fgChatInput');
338    var sendBtn = document.getElementById('fgChatSendBtn');
339    var confirmBtn = document.getElementById('fgChatConfirmBtn');
340    var broadcastBtn = document.getElementById('fgChatBroadcastBtn');
341    var manualBtn = document.getElementById('fgChatManualFallbackBtn');
342    var fallbackActions = root.querySelector('.fg-chat-actions--fallback');
343
344    // The fallback action row is hidden by default; only reveal it when we actually
345    // need it (assistant unavailable / network error), so it never duplicates the
346    // in-chat Confirm/Edit chips.
347    function showFallbackActions() {
348      if (fallbackActions) fallbackActions.hidden = false;
349    }
350
351    var stateBanner = document.getElementById('fgChatState');
352    var stateLabel = document.getElementById('fgChatStateLabel');
353
354    var sessionId = null;
355    var lastDraft = {};
356    var lastLinePriceGuide = null;
357    var PRODUCT_PLACEHOLDER = '/images/craving-go/product-placeholder.svg';
358    var MAP_PIN_PLACEHOLDER = '/images/craving-go/category-placeholder.svg';
359    var createdRequestId = null;
360    var confirmationRejected = false;
361    // Phase 19A-Fix2: request sequencing so out-of-order/stale responses are ignored
362    // and the user can't double-submit while a turn is in flight.
363    var reqSeq = 0;
364    var appliedSeq = 0;
365    var pending = false;
366    // Phase 19A-Fix5: last assistant bubble text, to avoid showing the same prompt twice.
367    var lastAssistantText = '';
368    var renderedOfferIds = {};
369    var storageKey = 'fgCravingGoActiveChat';
370    // Phase 19A-Fix10.4: session lifecycle. `chatClosed` means the current session is
371    // terminal/read-only (completed/cancelled/expired) — typing should start a fresh
372    // craving rather than append to a dead session.
373    var chatClosed = false;
374    var lifecycleState = null;
375    var greetingText = root.getAttribute('data-assistant-greeting')
376      || 'Hi — tell me what you need and I\'ll help nearby sellers respond.';
377    var assistantName = root.getAttribute('data-assistant-name') || 'Go';
378    var askGoLabel = root.getAttribute('data-ask-go-label') || 'Ask Go a question';
379    var backSellerLabel = root.getAttribute('data-back-seller-label') || 'Back to seller chat';
380    var GO_ORB_BASE = '/images/craving-go/orbs/';
381    var goIconUrl = root.getAttribute('data-go-icon-url') || (GO_ORB_BASE + 'go-ai-orb-01-active-online.png');
382    var goReady = root.getAttribute('data-go-ready') === '1';
383    var goAvatars = {
384      active: GO_ORB_BASE + 'go-ai-orb-01-active-online.png',
385      sleeping: GO_ORB_BASE + 'go-ai-orb-02-sleeping-offline.png',
386      greeting: GO_ORB_BASE + 'go-ai-orb-03-happy-greeting.png',
387      thinking: GO_ORB_BASE + 'go-ai-orb-04-thinking.png',
388      typing: GO_ORB_BASE + 'go-ai-orb-05-typing-waiting.png',
389      excited: GO_ORB_BASE + 'go-ai-orb-06-excited-lets-go.png',
390      delivery: GO_ORB_BASE + 'go-ai-orb-07-delivery.png',
391      assistant: GO_ORB_BASE + 'go-ai-orb-08-assistant.png',
392    };
393    try {
394      var avatarsRaw = root.getAttribute('data-go-avatars');
395      if (avatarsRaw) {
396        var parsedAvatars = JSON.parse(decodeURIComponent(avatarsRaw));
397        if (parsedAvatars && typeof parsedAvatars === 'object') {
398          Object.keys(parsedAvatars).forEach(function (k) {
399            if (parsedAvatars[k]) goAvatars[k] = parsedAvatars[k];
400          });
401        }
402      }
403    } catch (err) {}
404    var defaultPlaceholder = input ? (input.getAttribute('placeholder') || 'Tell me what you\'re craving…') : '';
405    var closedPlaceholder = root.getAttribute('data-closed-placeholder') || 'Start a new craving to continue…';
406
407    function avatarUrlFor(key) {
408      var k = key || 'assistant';
409      if (goAvatars[k]) return goAvatars[k];
410      return goAvatars.assistant || goIconUrl;
411    }
412
413    function createAssistantAvatar(avatarKey) {
414      var wrap = document.createElement('span');
415      var isTyping = avatarKey === 'typing';
416      var isActiveState = goReady && avatarKey !== 'sleeping';
417      wrap.className = 'fg-chat-msg__avatar-wrap'
418        + (isTyping || isActiveState ? ' fg-chat-msg__avatar-wrap--ready' : ' fg-chat-msg__avatar-wrap--sleep');
419      var img = document.createElement('img');
420      img.className = 'fg-chat-msg__avatar';
421      img.src = avatarUrlFor(avatarKey);
422      img.alt = '';
423      img.setAttribute('aria-hidden', 'true');
424      wrap.appendChild(img);
425      return wrap;
426    }
427
428    // A real participant (the seller) avatar for group-chat message rows.
429    function createParticipantAvatar(src) {
430      var wrap = document.createElement('span');
431      wrap.className = 'fg-chat-msg__avatar-wrap fg-chat-msg__avatar-wrap--seller';
432      var img = document.createElement('img');
433      img.className = 'fg-chat-msg__avatar';
434      img.src = src || '/images/default-avatar.png';
435      img.alt = '';
436      img.setAttribute('aria-hidden', 'true');
437      img.addEventListener('error', function () { img.src = '/images/default-avatar.png'; });
438      wrap.appendChild(img);
439      return wrap;
440    }
441
442    var TERMINAL_LIFECYCLE_STATES = ['completed', 'cancelled', 'expired', 'inactive_archived'];
443    function isTerminalLifecycle(state) {
444      return TERMINAL_LIFECYCLE_STATES.indexOf(String(state || '')) !== -1;
445    }
446
447    function persistChatState() {
448      if (!window.localStorage || !log) return;
449      try {
450        window.localStorage.setItem(storageKey, JSON.stringify({
451          requestId: createdRequestId || '',
452          sessionId: sessionId || '',
453          html: log.innerHTML,
454          draft: lastDraft || {},
455          linePriceGuide: lastLinePriceGuide || null,
456          savedAt: Date.now(),
457        }));
458      } catch (err) {}
459    }
460
461    function restoreChatState() {
462      if (!window.localStorage || !log) return false;
463      try {
464        var raw = window.localStorage.getItem(storageKey);
465        if (!raw) return false;
466        var state = JSON.parse(raw);
467        if (!state || !state.html) return false;
468        if (state.requestId) {
469          createdRequestId = state.requestId;
470          document.body.setAttribute('data-fg-current-broadcast-request-id', createdRequestId);
471        }
472        if (state.sessionId) sessionId = state.sessionId;
473        log.innerHTML = state.html;
474        refreshMessageGrouping();
475        Array.prototype.forEach.call(log.querySelectorAll('.fg-chat-action-chips'), function (group) {
476          if (group && group.parentNode) group.parentNode.removeChild(group);
477        });
478        // Strip any stale "<seller> joined the chat" cards baked into an older
479        // saved transcript — they're no longer rendered (landed out of order);
480        // the group header carries the join signal now.
481        Array.prototype.forEach.call(log.querySelectorAll('.fg-chat-joined'), function (node) {
482          var row = node.closest ? node.closest('.fg-chat-msg') : node.parentNode;
483          if (row && row.parentNode) row.parentNode.removeChild(row);
484        });
485        lastDraft = state.draft || {};
486        lastLinePriceGuide = state.linePriceGuide || null;
487        renderDraft(lastDraft);
488        log.scrollTop = log.scrollHeight;
489        if (createdRequestId) {
490          // Phase 19A-Fix10.4: backend status is authoritative — don't blindly resume.
491          applyRestoredRequestState(createdRequestId);
492        }
493        return true;
494      } catch (err) {
495        return false;
496      }
497    }
498
499    // Phase 19A-Fix10.4 — session lifecycle UX helpers.
500    function collapseRequestDetails() {
501      var details = document.getElementById('fgChatDetails');
502      if (details) details.open = false;
503    }
504
505    function removeReviewCards() {
506      if (!log) return;
507      Array.prototype.forEach.call(log.querySelectorAll('.fg-chat-review'), function (card) {
508        if (card && card.parentNode) card.parentNode.removeChild(card);
509      });
510    }
511
512    // Put the chat into read-only/closed mode (terminal request or archived session).
513    function setChatClosed(closed) {
514      chatClosed = !!closed;
515      if (input) {
516        input.setAttribute('placeholder', chatClosed ? closedPlaceholder : defaultPlaceholder);
517      }
518      if (chatClosed) collapseRequestDetails();
519    }
520
521    // Clear ALL active draft/request state and start a fresh Craving Go chat. Does
522    // NOT delete completed request history (server keeps the request docs).
523    // A live/accepted/paid order must never be wiped from view by a stray tap.
524    // When one is active, confirm first — the backend request stays open under
525    // View request either way; this only clears the chat thread.
526    var TERMINAL_LIFECYCLE = ['cancelled', 'expired', 'completed', 'inactive_archived'];
527    function hasLiveOrder() {
528      var hasRequest = !!createdRequestId
529        || !!document.body.getAttribute('data-fg-current-broadcast-request-id');
530      return hasRequest && TERMINAL_LIFECYCLE.indexOf(lifecycleState) === -1;
531    }
532    function requestStartNewCraving() {
533      if (!hasLiveOrder()) { startNewCraving(); return; }
534      appendMessage('assistant', 'You have an active order in progress. It stays open under View request — starting a new craving just clears this chat. Start a new one anyway?');
535      renderChatActions([
536        { action: 'start_new_craving_confirm', label: 'Start new craving', kind: 'primary' },
537        { action: 'keep_request', label: 'Keep my order', kind: 'basic' }
538      ]);
539    }
540
541    function startNewCraving(prefillMessage) {
542      try { if (window.localStorage) window.localStorage.removeItem(storageKey); } catch (e) {}
543      sessionId = null;
544      createdRequestId = null;
545      confirmationRejected = false;
546      renderedOfferIds = {};
547      lastDraft = {};
548      lastLinePriceGuide = null;
549      lastAssistantText = '';
550      lifecycleState = null;
551      document.body.removeAttribute('data-fg-current-broadcast-request-id');
552      if (log) log.innerHTML = '';
553      var relayComposerEl = document.getElementById('fgChatRelayComposer');
554      if (relayComposerEl) relayComposerEl.hidden = true;
555      setChatClosed(false);
556      appendMessage('assistant', greetingText, 'greeting');
557      renderDraft({});
558      collapseRequestDetails();
559      if (input) {
560        input.disabled = false;
561        input.value = '';
562        try { input.focus(); } catch (e) {}
563      }
564      if (prefillMessage) send(prefillMessage);
565    }
566
567    // Decide how to render a restored request based on its AUTHORITATIVE backend
568    // status — resume an active draft/request, or show a read-only closed state.
569    function applyRestoredRequestState(requestId) {
570      if (!requestId) return;
571      requestJson('/broadcast-requests/' + requestId, { method: 'GET' }).then(function (json) {
572        var request = json && json.broadcastRequest;
573        var status = request && request.status;
574        if (!request) { refreshRestoredRequestActions(requestId); return; }
575        if (status === 'cancelled' || status === 'expired' || status === 'completed') {
576          lifecycleState = status;
577          setChatClosed(true);
578          appendAssistantOnce(status === 'cancelled'
579            ? 'Your craving request was cancelled. This craving is closed.'
580            : (status === 'expired' ? 'This craving expired. This craving is closed.' : 'This craving is complete.'));
581          var closedActions = [];
582          if (status === 'completed' && !(json && json.alreadyReviewed)) {
583            closedActions.push({ action: 'rate_experience', label: 'Rate this experience', kind: 'primary', value: requestId });
584          }
585          closedActions.push({ action: 'start_new_craving', label: 'Start new craving', kind: closedActions.length ? 'basic' : 'primary' });
586          closedActions.push({ action: 'view_request_history', label: 'View request history', kind: 'basic' });
587          renderChatActions(closedActions);
588          fetchChatRelay();
589          return;
590        }
591        if (status === 'payment_pending') {
592          lifecycleState = 'payment_needed';
593          appendAssistantOnce('Your craving has an accepted offer awaiting payment.');
594          renderPaymentActions(requestId);
595          renderChatActions([{ action: 'start_new_craving', label: 'Start new craving', kind: 'basic' }]);
596          fetchChatRelay();
597          return;
598        }
599        // Live request (broadcasting / offers / accepted) — keep it viewable; the
600        // session chat is done, so guide to View request or a new craving.
601        // Only narrate "sent to nearby sellers" while still AWAITING offers. Once
602        // accepted/in fulfillment that line is stale and, appended on restore,
603        // lands out of order (below the seller chat). The accept announcement and
604        // the canonical timeline copy (fetchAndRenderOffer) carry the live state.
605        var awaitingOffers = ['broadcasting', 'negotiation_open', 'offer_received'].indexOf(status) !== -1;
606        if (awaitingOffers) {
607          appendAssistantOnce('Your craving was sent to nearby sellers.');
608        }
609        lifecycleState = status || lifecycleState;
610        renderChatActions([
611          { action: 'view_request', label: 'View request', kind: 'primary', value: requestId },
612          { action: 'start_new_craving', label: 'Start new craving', kind: 'basic' }
613        ]);
614        fetchChatRelay();
615        fetchAndRenderOffer(requestId);
616      }).catch(function () {
617        refreshRestoredRequestActions(requestId);
618      });
619    }
620
621    // Phase 19A-Fix5: friendly current-step indicator in the chat header.
622    function setConversationState(label) {
623      if (!stateBanner || !stateLabel) return;
624      if (!label) { stateBanner.hidden = true; return; }
625      stateLabel.textContent = label;
626      stateBanner.hidden = false;
627    }
628
629    function refreshMessageGrouping() {
630      if (!log) return;
631      var rows = log.querySelectorAll('.fg-chat-msg');
632      Array.prototype.forEach.call(rows, function (row) {
633        row.classList.remove('fg-chat-msg--continued');
634      });
635      for (var i = 0; i < rows.length; i++) {
636        var row = rows[i];
637        if (row.classList.contains('fg-chat-msg--typing')) continue;
638        var role = row.classList.contains('fg-chat-msg--user') ? 'user'
639          : (row.classList.contains('fg-chat-msg--assistant') ? 'assistant'
640            : (row.classList.contains('fg-chat-msg--seller') ? 'seller' : null));
641        if (!role) continue;
642        var prev = i > 0 ? rows[i - 1] : null;
643        if (prev && !prev.classList.contains('fg-chat-msg--typing')
644          && prev.classList.contains('fg-chat-msg--' + role)) {
645          row.classList.add('fg-chat-msg--continued');
646        }
647      }
648    }
649
650    function appendMessage(role, text, avatarKey) {
651      if (!log || !text) return null;
652      var row = document.createElement('div');
653      row.className = 'fg-chat-msg fg-chat-msg--' + (role === 'user' ? 'user' : 'assistant');
654      if (role !== 'user') row.appendChild(createAssistantAvatar(avatarKey || 'assistant'));
655      var bubble = document.createElement('div');
656      bubble.className = 'fg-chat-bubble';
657      bubble.textContent = text;
658      row.appendChild(bubble);
659      log.appendChild(row);
660      refreshMessageGrouping();
661      log.scrollTop = log.scrollHeight;
662      persistChatState();
663      return row;
664    }
665
666    // Phase 19A: attach a node (suggestion cards / action chips) to the most recent
667    // assistant message so it reads as part of that bubble's turn.
668    function lastAssistantRow() {
669      if (!log) return null;
670      var rows = log.querySelectorAll('.fg-chat-msg--assistant');
671      return rows.length ? rows[rows.length - 1] : null;
672    }
673
674    function appendTypingIndicator() {
675      if (!log) return null;
676      var row = document.createElement('div');
677      row.className = 'fg-chat-msg fg-chat-msg--assistant fg-chat-msg--typing';
678      row.setAttribute('aria-label', assistantName + ' is typing');
679      row.appendChild(createAssistantAvatar('typing'));
680      var bubble = document.createElement('div');
681      bubble.className = 'fg-chat-bubble fg-chat-typing';
682      bubble.innerHTML = '<span></span><span></span><span></span>';
683      row.appendChild(bubble);
684      log.appendChild(row);
685      refreshMessageGrouping();
686      log.scrollTop = log.scrollHeight;
687      return row;
688    }
689
690    function removeTypingIndicator(row) {
691      if (row && row.parentNode) row.parentNode.removeChild(row);
692      refreshMessageGrouping();
693    }
694
695    // Phase 19A: product suggestion cards rendered INSIDE the chat. Tapping a card
696    // sends its itemName back through the normal conversation route — the client
697    // never sets price/seller/status; the assistant continues from there.
698    function renderSuggestionCards(suggestion) {
699      var row = lastAssistantRow();
700      if (!row || !suggestion || !suggestion.suggestions || !suggestion.suggestions.length) return;
701      var wrap = document.createElement('div');
702      wrap.className = 'fg-chat-cards';
703      wrap.setAttribute('role', 'list');
704      suggestion.suggestions.forEach(function (card) {
705        if (!card.category && suggestion.category) card.category = suggestion.category;
706        var isAnyCard = card.source === 'fallback_any' || card.source === 'brand_scope_any';
707        var isScopeOptions = card.source === 'brand_scope_options';
708        var btn = document.createElement('button');
709        btn.type = 'button';
710        btn.className = 'fg-chat-card-btn'
711          + (isAnyCard ? ' fg-chat-card-btn--any' : '')
712          + (isScopeOptions ? ' fg-chat-card-btn--options' : '');
713        btn.setAttribute('role', 'listitem');
714        btn.setAttribute('aria-label', card.label || card.itemName || 'Suggestion');
715        var img = document.createElement('img');
716        img.className = 'fg-chat-card-btn__img';
717        img.alt = '';
718        img.loading = 'lazy';
719        img.src = (card.imageUrl && card.imageUrl !== MAP_PIN_PLACEHOLDER)
720          ? card.imageUrl
721          : PRODUCT_PLACEHOLDER;
722        img.addEventListener('error', function () {
723          if (img.getAttribute('data-ph') === '1') return;
724          img.setAttribute('data-ph', '1');
725          img.src = PRODUCT_PLACEHOLDER;
726        });
727        btn.appendChild(img);
728        var label = document.createElement('span');
729        label.className = 'fg-chat-card-btn__label';
730        label.textContent = card.label || card.itemName || '';
731        btn.appendChild(label);
732        var subtitle = card.priceSubtitle
733          || (!isAnyCard && !isScopeOptions && card.listedPrice ? '~$' + card.listedPrice : '');
734        if (isAnyCard && card.priceSubtitle) subtitle = card.priceSubtitle;
735        if (subtitle) {
736          var price = document.createElement('span');
737          price.className = 'fg-chat-card-btn__price';
738          price.textContent = subtitle;
739          btn.appendChild(price);
740        }
741        var tagText = isAnyCard ? 'Any is fine'
742          : isScopeOptions ? 'Examples'
743          : card.source === 'inventory' ? 'Nearby listing'
744          : card.source === 'web_shopping' ? 'From the web'
745          : 'Popular choice';
746        var tag = document.createElement('span');
747        tag.className = 'fg-chat-card-btn__tag';
748        tag.textContent = tagText;
749        btn.appendChild(tag);
750        btn.addEventListener('click', function () {
751          if (btn.disabled) return;
752          // Lock the card set and mark the chosen one (no re-clarification).
753          Array.prototype.forEach.call(wrap.querySelectorAll('button'), function (b) {
754            b.disabled = true;
755            b.classList.remove('is-selected');
756          });
757          btn.classList.add('is-selected');
758          wrap.classList.add('is-resolved');
759          sendSelection(card);
760        });
761        wrap.appendChild(btn);
762      });
763      row.appendChild(wrap);
764      cardGroups.push(wrap);
765      if (log) log.scrollTop = log.scrollHeight;
766    }
767
768    // Phase 19A-Fix4: track every rendered action group so that when the state moves
769    // on (a newer turn applies), the OLD Confirm/Edit/chip buttons become inert — no
770    // stale, double-acting controls lingering in the transcript.
771    var actionGroups = [];
772    function invalidatePriorActionGroups() {
773      // Once a new turn lands, earlier action chips (Confirm / Edit / Yes, change item…)
774      // are superseded. The buyer's choice is already preserved as their own chat bubble,
775      // so remove the stale chips entirely rather than leaving greyed-out clutter behind.
776      actionGroups.forEach(function (g) {
777        if (g && g.parentNode) g.parentNode.removeChild(g);
778      });
779      actionGroups = [];
780    }
781    // Phase 19A-Fix5: product-choice cards collapse/disable once the turn moves on, so
782    // old catalogs don't linger as if still active.
783    var cardGroups = [];
784    function collapsePriorCardGroups() {
785      cardGroups.forEach(function (g) {
786        g.classList.add('is-resolved');
787        Array.prototype.forEach.call(g.querySelectorAll('button'), function (b) { b.disabled = true; });
788      });
789    }
790
791    // Your basket: confirm/send, add-line, or progressive after each resolved
792    // basket line (json.showReviewCard) — even while the next chooser is open.
793    function showsReviewCard(json) {
794      if (!json || !json.draft) return false;
795      if (json.readyToConfirm || json.readyForConfirmButton || json.showReviewCard) return true;
796      if (json.chatActions && json.chatActions.some(function (a) {
797        return a && (a.action === 'confirm' || a.action === 'send' || a.action === 'keep_basket_offer');
798      })) return true;
799      return /here'?s your basket/i.test(String(json.assistantMessage || ''));
800    }
801
802    // The plain-text summary is replaced by the styled card. Drop everything from the
803    // "Review this before I prepare it" marker onward; keep any friendly lead-in.
804    function stripReviewSummary(text) {
805      if (!text) return text;
806      var idx = text.indexOf('Review this before I prepare it');
807      var basketIdx = text.indexOf('Review this basket before I prepare it');
808      if (idx === -1 || (basketIdx !== -1 && basketIdx < idx)) idx = basketIdx;
809      if (idx === -1) return text;
810      return text.slice(0, idx).replace(/\s+$/, '');
811    }
812
813    // Styled "Your craving" card built from the structured draft, attached to the
814    // current assistant turn (above the Confirm/Edit chips).
815    function renderReviewCard(draft, serverGuide) {
816      if (!log || !draft) return;
817      // Rebuild on the Confirm/Edit turn so a $5 offer / listed Serp refs land
818      // even if a stale card is already attached to this assistant row.
819      removeReviewCards();
820      var row = lastAssistantRow();
821      if (!row) return;
822      function has(v) { return v !== undefined && v !== null && v !== ''; }
823      var lineItems = Array.isArray(draft.lineItems) ? draft.lineItems.filter(function (item) {
824        return item && item.itemName;
825      }) : [];
826      var isBasket = lineItems.length > 1;
827      if (!has(draft.itemName) && !isBasket && !has(draft.originalOfferedPrice) && !has(draft.originalNeededByMinutes)) return;
828      var priceGuide = (serverGuide && Array.isArray(serverGuide.lines) && serverGuide.lines.length)
829        ? serverGuide
830        : computeLinePriceGuide(draft);
831      var card = document.createElement('div');
832      card.className = 'fg-chat-review' + (isBasket ? ' fg-chat-review--basket' : '');
833      var head = document.createElement('div');
834      head.className = 'fg-chat-review__head';
835      head.textContent = isBasket ? 'Your basket' : 'Your craving';
836      card.appendChild(head);
837      function addRow(label, val) {
838        if (!has(val)) return;
839        var r = document.createElement('div');
840        r.className = 'fg-chat-review__row';
841        var l = document.createElement('span');
842        l.className = 'fg-chat-review__label';
843        l.textContent = label;
844        var v = document.createElement('span');
845        v.className = 'fg-chat-review__value';
846        v.textContent = val;
847        r.appendChild(l);
848        r.appendChild(v);
849        card.appendChild(r);
850      }
851      var qtyN = Number(draft.quantity);
852      var isMulti = has(draft.quantity) && Number.isFinite(qtyN) && qtyN > 1;
853      var price = has(draft.originalOfferedPrice)
854        ? (draft.currency ? draft.currency + ' ' : '') + '$' + draft.originalOfferedPrice + (isBasket ? ' total for the basket' : (isMulti ? ' total' : ''))
855        : '';
856      var fulfillment = has(draft.fulfillmentType)
857        ? String(draft.fulfillmentType).charAt(0).toUpperCase() + String(draft.fulfillmentType).slice(1)
858        : '';
859      if (isBasket) {
860        // Compact cart rows sit above the AI question so the prompt stays visible.
861        var itemsWrap = document.createElement('div');
862        itemsWrap.className = 'fg-chat-review__basket';
863        lineItems.forEach(function (item, i) {
864          var guideLine = priceGuide.lines[i] || null;
865          var lineRow = document.createElement('div');
866          lineRow.className = 'fg-chat-cart-line';
867
868          var top = document.createElement('div');
869          top.className = 'fg-chat-cart-line__top';
870          var identity = document.createElement('div');
871          identity.className = 'fg-chat-cart-line__identity';
872          var lineThumb = (guideLine && guideLine.imageUrl) || firstLineRefThumb(item);
873          if (lineThumb === MAP_PIN_PLACEHOLDER) lineThumb = '';
874          if (!lineThumb) lineThumb = PRODUCT_PLACEHOLDER;
875          if (lineThumb) {
876            var img = document.createElement('img');
877            img.className = 'fg-chat-cart-line__thumb';
878            img.alt = '';
879            img.src = lineThumb;
880            img.addEventListener('error', function () { img.src = PRODUCT_PLACEHOLDER; });
881            identity.appendChild(img);
882          }
883          var nameWrap = document.createElement('div');
884          var nameEl = document.createElement('span');
885          nameEl.className = 'fg-chat-cart-line__name';
886          nameEl.textContent = item.itemName;
887          nameWrap.appendChild(nameEl);
888          if (item.supplyType === 'homemade' || (guideLine && guideLine.homemade)) {
889            var homeChip = document.createElement('span');
890            homeChip.className = 'fg-chat-cart-line__homemade';
891            homeChip.textContent = 'Homemade';
892            nameWrap.appendChild(homeChip);
893          }
894          if (item.brandFlexible) {
895            var anyChip = document.createElement('span');
896            anyChip.className = 'fg-chat-cart-line__any';
897            anyChip.textContent = 'Any brand OK';
898            nameWrap.appendChild(anyChip);
899          }
900          identity.appendChild(nameWrap);
901          var qtyEl = document.createElement('span');
902          qtyEl.className = 'fg-chat-cart-line__qty';
903          var unit = (guideLine && guideLine.unitLabel) || (item.unitLabel ? String(item.unitLabel).trim() : (item.unitType || ''));
904          qtyEl.textContent = (guideLine && guideLine.qtyCopy) || (unit ? ((item.quantity || 1) + ' ' + unit) : ('×' + (item.quantity || 1)));
905          top.appendChild(identity);
906          top.appendChild(qtyEl);
907          lineRow.appendChild(top);
908          if (guideLine && guideLine.packCopy) {
909            var packEl = document.createElement('div');
910            packEl.className = 'fg-chat-cart-line__pack';
911            packEl.textContent = guideLine.packCopy;
912            lineRow.appendChild(packEl);
913          }
914          var refEl = document.createElement('div');
915          refEl.className = 'fg-chat-cart-line__ref';
916          refEl.textContent = (guideLine && guideLine.refCopy) || (item.brandFlexible ? 'Any — no ref price' : '—');
917          lineRow.appendChild(refEl);
918
919          var actions = document.createElement('div');
920          actions.className = 'fg-chat-cart-line__actions';
921          [
922            { kind: 'qty', label: 'Qty', action: 'edit_basket_line_qty' },
923            { kind: 'edit', label: 'Change', action: 'edit_basket_line' },
924            { kind: 'remove', label: 'Remove', action: 'remove_basket_line' },
925          ].forEach(function (la) {
926            var b = document.createElement('button');
927            b.type = 'button';
928            b.className = 'fg-chat-cart-link fg-chat-cart-link--' + la.kind;
929            b.textContent = la.label;
930            b.addEventListener('click', function () {
931              if (chatClosed) return;
932              b.disabled = true;
933              handleChatAction(la.action, b, null, String(i));
934            });
935            actions.appendChild(b);
936          });
937          lineRow.appendChild(actions);
938          itemsWrap.appendChild(lineRow);
939        });
940        card.appendChild(itemsWrap);
941      } else {
942        addRow('Item', (priceGuide.lines[0] && priceGuide.lines[0].label) || draft.itemName);
943        var singleLine = lineItems[0] || draft;
944        var singleQty = (priceGuide.lines[0] && priceGuide.lines[0].qtyCopy)
945          || (function () {
946            var singleUnit = (priceGuide.lines[0] && priceGuide.lines[0].unitLabel)
947              || singleLine.unitLabel || singleLine.unitType || '';
948            return has(draft.quantity)
949              ? (singleUnit ? (draft.quantity + ' ' + singleUnit) : String(draft.quantity))
950              : '';
951          }());
952        addRow('Quantity', singleQty);
953        if (priceGuide.lines[0] && priceGuide.lines[0].packCopy) addRow('Pack', priceGuide.lines[0].packCopy);
954        if (priceGuide.lines[0] && priceGuide.lines[0].refCopy) {
955          addRow('Ref / Est.', priceGuide.lines[0].refCopy);
956        }
957      }
958      if (priceGuide.footerCopy) addRow(/est\./i.test(priceGuide.footerCopy) ? 'Refs / est.' : 'Refs suggest', priceGuide.footerCopy);
959      addRow(isBasket ? 'Your offer' : (isMulti ? 'Offer (total)' : 'Your offer'), priceGuide.offerCopy || price);
960      if (priceGuide.offerVsCopy) {
961        var vsNote = document.createElement('p');
962        vsNote.className = 'fg-chat-review__vs';
963        vsNote.textContent = priceGuide.offerVsCopy + '. Estimate only — not a listing or charge.';
964        card.appendChild(vsNote);
965      }
966      if (priceGuide.lowCopy) {
967        var lowNote = document.createElement('p');
968        lowNote.className = 'fg-chat-review__low';
969        lowNote.textContent = priceGuide.lowCopy;
970        card.appendChild(lowNote);
971      }
972      addRow('Needed in', has(draft.originalNeededByMinutes) ? draft.originalNeededByMinutes + ' min' : '');
973      addRow('Fulfillment', fulfillment);
974      if (!isBasket) appendReferenceThumbs(card, lineItems.length ? lineItems : []);
975      // Basket above the prompt: otherwise a 4-line cart sits on the question.
976      var bubble = row.querySelector('.fg-chat-bubble');
977      if (bubble && bubble.parentNode === row) row.insertBefore(card, bubble);
978      else row.appendChild(card);
979      if (log) {
980        var prompt = row.querySelector('.fg-chat-bubble') || row;
981        if (prompt && typeof prompt.scrollIntoView === 'function') {
982          try { prompt.scrollIntoView({ block: 'end' }); } catch (err) { log.scrollTop = log.scrollHeight; }
983        } else {
984          log.scrollTop = log.scrollHeight;
985        }
986      }
987      persistChatState();
988    }
989
990    // Fallback only. Server `linePriceGuide` from requestLineDisplay.js
991    // (buyerGuide / sellerGuide) is source of truth — keep this aligned with
992    // priceIntelligenceService.lineRefDisplay; do not invent a second audience.
993    function computeLinePriceGuide(draft) {
994      var lines = Array.isArray(draft && draft.lineItems) ? draft.lineItems.filter(function (item) {
995        return item && item.itemName;
996      }) : [];
997      function num(v) {
998        var n = Number(v);
999        return Number.isFinite(n) && n > 0 ? n : null;
1000      }
1001      function displayFor(line) {
1002        var qty = Number(line.quantity);
1003        if (!Number.isFinite(qty) || qty < 1) qty = 1;
1004        var refs = Array.isArray(line.references) ? line.references : [];
1005        var priced = null;
1006        var visual = null;
1007        refs.forEach(function (ref) {
1008          if (!ref || !ref.source) return;
1009          if ((ref.source === 'serp' || ref.source === 'local') && num(ref.price) && !priced) priced = ref;
1010          if (ref.source !== 'any' && !visual) visual = ref;
1011        });
1012        visual = priced || visual;
1013        var unit = priced ? num(priced.price) : null;
1014        var estimate = num(line.estimateUnitPrice);
1015        var packLabel = String((priced && priced.packSizeLabel) || line.packSizeLabel || '').trim();
1016        var mode = String(line.refPriceMode || '').trim()
1017          || (priced && packLabel && qty !== 1 ? 'listing' : (priced ? 'each' : ''));
1018        var lineRefTotal = null;
1019        var refCopy = 'Any — no ref price';
1020        var pieceAsk = /^(piece|bulb|clove)$/i.test(String(line.unitType || ''));
1021        if (unit != null) {
1022          if (pieceAsk && !packLabel && mode !== 'pack') {
1023            lineRefTotal = null;
1024            refCopy = 'Size unclear — not used as each';
1025          } else if (mode === 'pack' || mode === 'listing' || mode === 'example') {
1026            lineRefTotal = unit;
1027            refCopy = 'Ref ~$' + unit + (mode === 'pack' ? ' / pack' : '') + ' · ' + (packLabel || 'size unclear');
1028          } else {
1029            lineRefTotal = Number((unit * qty).toFixed(2));
1030            refCopy = 'Ref ~$' + unit + (line.unitType === 'kg' ? '/kg' : '');
1031            if (packLabel) refCopy += ' · ' + packLabel;
1032          }
1033        } else if (estimate != null) {
1034          lineRefTotal = Number((estimate * qty).toFixed(2));
1035          refCopy = line.estimateLabel || ('Est. ~$' + estimate + (line.unitType === 'kg' ? '/kg' : ' each'));
1036        } else if (!line.brandFlexible) {
1037          refCopy = '—';
1038        }
1039        var img = visual && visual.imageUrl && visual.imageUrl !== MAP_PIN_PLACEHOLDER ? visual.imageUrl : '';
1040        var unitLabel = String(line.unitLabel || line.unitType || '').trim();
1041        var hasSpecificRef = !!(visual && visual.source && visual.source !== 'any');
1042        var packCopy = packLabel || (hasSpecificRef ? 'size unclear' : '');
1043        return {
1044          label: line.itemName,
1045          qty: qty,
1046          qtyCopy: unitLabel ? (qty + ' ' + unitLabel) : ('x' + qty),
1047          unitLabel: unitLabel,
1048          brandFlexible: line.brandFlexible === true,
1049          homemade: line.supplyType === 'homemade',
1050          hasSpecificRef: hasSpecificRef,
1051          imageUrl: img,
1052          packSizeLabel: packLabel,
1053          packCopy: packCopy,
1054          unitRefPrice: unit,
1055          lineRefTotal: lineRefTotal,
1056          refCopy: refCopy,
1057        };
1058      }
1059      var shown = lines.map(displayFor);
1060      var known = shown.filter(function (line) { return line.lineRefTotal != null; });
1061      var refsSuggestTotal = known.length
1062        ? Number(known.reduce(function (sum, line) { return sum + line.lineRefTotal; }, 0).toFixed(2))
1063        : null;
1064      var offer = num(draft && draft.originalOfferedPrice);
1065      var missingQty = shown.reduce(function (n, line) {
1066        return line.lineRefTotal == null ? n + (line.qty || 1) : n;
1067      }, 0);
1068      var lowVsRefs = !!(refsSuggestTotal && offer && offer < refsSuggestTotal * 0.65);
1069      if (!lowVsRefs && refsSuggestTotal && offer && missingQty >= 1) {
1070        lowVsRefs = (offer - refsSuggestTotal) < missingQty * 1.25;
1071      }
1072      var footerCopy = refsSuggestTotal != null
1073        ? ((shown.some(function (line) { return /est\./i.test(String(line.refCopy || '')); })
1074          ? 'Refs/est. suggest ~$'
1075          : 'Refs suggest ~$') + refsSuggestTotal + ' total')
1076        : '';
1077      if (footerCopy && shown.some(function (line) { return line.lineRefTotal == null; })) {
1078        footerCopy += ' (some lines have no reference price)';
1079      }
1080      return {
1081        lines: shown,
1082        refsSuggestTotal: refsSuggestTotal,
1083        offer: offer,
1084        footerCopy: footerCopy,
1085        lowCopy: lowVsRefs
1086          ? 'This offer looks low vs reference prices — you can still confirm. This is an estimate, not a per-item charge.'
1087          : '',
1088      };
1089    }
1090
1091    function firstLineRefThumb(line) {
1092      var refs = line && Array.isArray(line.references) ? line.references : [];
1093      for (var i = 0; i < refs.length; i += 1) {
1094        if (refs[i] && refs[i].source !== 'any' && refs[i].imageUrl && refs[i].imageUrl !== MAP_PIN_PLACEHOLDER) {
1095          return refs[i].imageUrl;
1096        }
1097      }
1098      return '';
1099    }
1100
1101    function appendReferenceThumbs(parent, lines) {
1102      if (!parent || !Array.isArray(lines) || !lines.length) return;
1103      var anyBrand = lines.some(function (li) { return li && li.brandFlexible; });
1104      var refs = [];
1105      lines.forEach(function (li) {
1106        (li && li.references ? li.references : []).forEach(function (r) {
1107          if (r && r.source !== 'any' && (r.title || r.imageUrl)) refs.push(r);
1108        });
1109      });
1110      if (!anyBrand && !refs.length) return;
1111      var wrap = document.createElement('div');
1112      wrap.className = 'fg-request-refs';
1113      if (anyBrand) {
1114        var chip = document.createElement('span');
1115        chip.className = 'fg-request-refs__any';
1116        chip.textContent = 'Any brand OK';
1117        wrap.appendChild(chip);
1118      }
1119      refs.forEach(function (r) {
1120        var item = document.createElement('div');
1121        item.className = 'fg-request-refs__item';
1122        if (r.imageUrl && r.imageUrl !== MAP_PIN_PLACEHOLDER) {
1123          var img = document.createElement('img');
1124          img.className = 'fg-request-refs__img';
1125          img.alt = '';
1126          img.src = r.imageUrl;
1127          img.addEventListener('error', function () { img.src = PRODUCT_PLACEHOLDER; });
1128          item.appendChild(img);
1129        }
1130        var t = document.createElement('span');
1131        t.className = 'fg-request-refs__title';
1132        t.textContent = r.title || '';
1133        item.appendChild(t);
1134        wrap.appendChild(item);
1135      });
1136      parent.appendChild(wrap);
1137    }
1138
1139    function renderRequestDetailsCard(request) {
1140      if (!request || !log) return;
1141      appendMessage('assistant', 'Here are the request details. Your chat is still here.');
1142      var row = lastAssistantRow();
1143      if (!row) return;
1144      var card = document.createElement('div');
1145      card.className = 'fg-chat-offer-card fg-chat-request-card';
1146      var head = document.createElement('div');
1147      head.className = 'fg-chat-offer-card__head';
1148      var title = document.createElement('strong');
1149      var requestLineItems = request.lineItems && request.lineItems.length > 1 ? request.lineItems : [];
1150      title.textContent = requestLineItems.length > 1 ? requestLineItems.length + '-item basket' : (request.itemName || 'Craving request');
1151      var status = document.createElement('span');
1152      status.textContent = String(request.status || '').replace(/_/g, ' ');
1153      head.appendChild(title);
1154      head.appendChild(status);
1155      card.appendChild(head);
1156      if (requestLineItems.length > 1) {
1157        var items = document.createElement('div');
1158        items.className = 'fg-chat-offer-card__items';
1159        requestLineItems.forEach(function (item) {
1160          var row = document.createElement('div');
1161          row.className = 'fg-chat-cart-line__top';
1162          var identity = document.createElement('div');
1163          identity.className = 'fg-chat-cart-line__identity';
1164          var thumb = firstLineRefThumb(item);
1165          if (thumb === MAP_PIN_PLACEHOLDER) thumb = '';
1166          if (!thumb && !item.brandFlexible) thumb = PRODUCT_PLACEHOLDER;
1167          if (thumb) {
1168            var img = document.createElement('img');
1169            img.className = 'fg-chat-cart-line__thumb';
1170            img.alt = '';
1171            img.src = thumb;
1172            img.addEventListener('error', function () { img.src = PRODUCT_PLACEHOLDER; });
1173            identity.appendChild(img);
1174          }
1175          var nameEl = document.createElement('span');
1176          nameEl.textContent = (item.itemName || '') + ' x' + (item.quantity || '?');
1177          identity.appendChild(nameEl);
1178          if (item.supplyType === 'homemade') {
1179            var homeChip = document.createElement('span');
1180            homeChip.className = 'fg-chat-cart-line__homemade';
1181            homeChip.textContent = 'Homemade';
1182            identity.appendChild(homeChip);
1183          }
1184          if (item.brandFlexible) {
1185            var anyChip = document.createElement('span');
1186            anyChip.className = 'fg-chat-cart-line__any';
1187            anyChip.textContent = 'Any brand OK';
1188            identity.appendChild(anyChip);
1189          }
1190          row.appendChild(identity);
1191          var refHint = document.createElement('span');
1192          refHint.className = 'fg-chat-cart-line__ref';
1193          var priced = Array.isArray(item.references) && item.references.filter(function (r) {
1194            return r && (r.source === 'serp' || r.source === 'local') && Number(r.price) > 0;
1195          })[0];
1196          refHint.textContent = priced ? ('Ref ~$' + Number(priced.price)) : (item.brandFlexible ? 'Any — no ref price' : 'No ref price');
1197          row.appendChild(refHint);
1198          items.appendChild(row);
1199        });
1200        card.appendChild(items);
1201      }
1202      appendReferenceThumbs(card, Array.isArray(request.lineItems) ? request.lineItems : []);
1203      var meta = document.createElement('div');
1204      meta.className = 'fg-chat-offer-card__meta';
1205      meta.textContent = '$' + Number(request.finalPrice || request.originalOfferedPrice || 0).toFixed(2)
1206        + ' · ' + (request.finalDeliveryMinutes || request.originalNeededByMinutes || '—') + ' min';
1207      card.appendChild(meta);
1208      var actions = document.createElement('div');
1209      actions.className = 'fg-chat-offer-card__actions';
1210      var open = document.createElement('a');
1211      open.className = 'fg-chat-offer-card__btn fg-chat-offer-card__btn--basic';
1212      open.href = '/broadcast-requests/' + request._id + '?returnTo=craving-chat';
1213      open.textContent = request.status === 'payment_pending' ? 'Open payment page' : 'Open full page';
1214      actions.appendChild(open);
1215      card.appendChild(actions);
1216      row.appendChild(card);
1217      log.scrollTop = log.scrollHeight;
1218      persistChatState();
1219    }
1220
1221    function showRequestDetails() {
1222      if (!createdRequestId) return;
1223      requestJson('/broadcast-requests/' + createdRequestId, { method: 'GET' }).then(function (json) {
1224        renderRequestDetailsCard(json.broadcastRequest);
1225      }).catch(function () {
1226        window.location.href = '/broadcast-requests/' + createdRequestId + '?returnTo=craving-chat';
1227      });
1228    }
1229
1230    function renderPaymentActions(requestId) {
1231      if (!requestId) return;
1232      renderChatActions([
1233        { action: 'pay', label: 'Open payment page', kind: 'primary', value: requestId },
1234        { action: 'view', label: 'View request', kind: 'basic' },
1235      ]);
1236    }
1237
1238    function refreshRestoredRequestActions(requestId) {
1239      if (!requestId) return;
1240      requestJson('/broadcast-requests/' + requestId, { method: 'GET' }).then(function (json) {
1241        var request = json && json.broadcastRequest;
1242        var ui = json && json.uiState;
1243        // Phase 19E-Fix2: a restored transcript can contain stale Pay CTAs from
1244        // before authorization — the canonical state decides what survives.
1245        // applyBuyerUiState now appends the canonical timeline copy and any
1246        // sanctioned CTA (pay / confirm_delivery) — no duplicate append here.
1247        applyBuyerUiState(ui);
1248        if (request && request.status === 'payment_pending') {
1249          renderPaymentActions(requestId);
1250        } else if (!(ui && ui.primaryCta && ui.primaryCta.action === 'confirm_delivery')) {
1251          renderChatActions([{ action: 'view', label: 'View request', kind: 'basic' }]);
1252        }
1253      }).catch(function () {
1254        renderChatActions([{ action: 'view', label: 'View request', kind: 'basic' }]);
1255      });
1256    }
1257
1258    function formatMoney(value) {
1259      var n = Number(value || 0);
1260      return '$' + n.toFixed(2);
1261    }
1262
1263    // Short local time (e.g. "3:42 PM") for message timestamps. Falls back to an
1264    // empty string for unparseable dates so a bad value never breaks rendering.
1265    function formatChatTime(value) {
1266      try {
1267        var d = new Date(value);
1268        if (isNaN(d.getTime())) return '';
1269        var t = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
1270        // Bare time for today; prepend a short date for older messages.
1271        if (d.toDateString() === new Date().toDateString()) return t;
1272        return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + t;
1273      } catch (e) { return ''; }
1274    }
1275
1276    function offerDisplayPrice(offer) {
1277      if (!offer) return null;
1278      return offer.finalPrice != null ? offer.finalPrice
1279        : offer.proposedPrice != null ? offer.proposedPrice
1280          : offer.originalPrice;
1281    }
1282
1283    function renderOfferCard(offer, recommendation, opts) {
1284      if (!offer || !offer._id || !log) return;
1285      var basket = offer.lineItems && offer.lineItems.length > 1;
1286      // Phase 19E-Fix2: name WHO accepted, and say what happens next — the
1287      // anonymous "A seller accepted" + bare status left buyers unsure whether
1288      // payment was still needed.
1289      var offerSellerName = offer.seller && offer.seller.displayName ? offer.seller.displayName : 'A nearby seller';
1290      // Phase 32-Fix: escrow holds the payment at post time, so an accepted escrow
1291      // offer must NOT tell the buyer to "authorize payment" again — that step is
1292      // already done. paymentNeeded comes from the canonical buyer UI state.
1293      var paymentNeeded = !!(opts && opts.paymentNeeded);
1294      var announce;
1295      if (offer.responseStatus === 'accepted_original') {
1296        announce = offerSellerName + ' accepted your ' + (basket ? 'full basket ' : '') + 'offer. ' + (paymentNeeded
1297          ? ('Next step: authorize payment so ' + offerSellerName + ' can prepare your order.')
1298          : ('Your payment hold is in place — ' + offerSellerName + ' is preparing your order.'));
1299      } else {
1300        announce = basket ? offerSellerName + ' sent an offer for your full basket.' : offerSellerName + ' sent an offer.';
1301      }
1302      // appendAssistantOnce dedupes across restores/polls (returns the existing
1303      // row when the bubble is already there) so the card attaches in one place.
1304      var row = appendAssistantOnce(announce);
1305      renderedOfferIds[offer._id] = true;
1306      if (!row) return;
1307      var existing = row.querySelector('[data-chat-offer-id="' + offer._id + '"]');
1308      if (existing && existing.parentNode) existing.parentNode.removeChild(existing);
1309
1310      var card = document.createElement('div');
1311      card.className = 'fg-chat-offer-card';
1312      card.setAttribute('data-chat-offer-id', offer._id);
1313      var sellerName = offer.seller && offer.seller.displayName ? offer.seller.displayName : 'Nearby seller';
1314      var price = offerDisplayPrice(offer);
1315      var minutes = offer.finalDeliveryMinutes || offer.proposedDeliveryMinutes || offer.originalDeliveryMinutes || '—';
1316      var fulfillment = offer.finalFulfillmentType || offer.proposedFulfillmentType || offer.originalFulfillmentType || 'delivery';
1317
1318      var head = document.createElement('div');
1319      head.className = 'fg-chat-offer-card__head';
1320      var title = document.createElement('strong');
1321      title.textContent = sellerName;
1322      var status = document.createElement('span');
1323      status.textContent = String(offer.responseStatus || '').replace(/_/g, ' ');
1324      head.appendChild(title);
1325      head.appendChild(status);
1326      card.appendChild(head);
1327
1328      var meta = document.createElement('div');
1329      meta.className = 'fg-chat-offer-card__meta';
1330      meta.textContent = formatMoney(price) + ' · ' + minutes + ' min · ' + fulfillment;
1331      card.appendChild(meta);
1332
1333      if (offer.lineItems && offer.lineItems.length > 1) {
1334        var items = document.createElement('div');
1335        items.className = 'fg-chat-offer-card__items';
1336        items.textContent = offer.lineItems.map(function (item) {
1337          return item.itemName + ' x' + (item.quantity || '?');
1338        }).join('\n');
1339        card.appendChild(items);
1340      }
1341
1342      if (offer.sellerMessage) {
1343        var note = document.createElement('p');
1344        note.className = 'fg-chat-offer-card__note';
1345        note.textContent = offer.sellerMessage;
1346        card.appendChild(note);
1347      }
1348
1349      if (recommendation && recommendation.recommendedOfferId === offer._id && recommendation.summary) {
1350        var rec = document.createElement('p');
1351        rec.className = 'fg-chat-offer-card__recommendation';
1352        rec.textContent = recommendation.summary;
1353        card.appendChild(rec);
1354        // Phase 19B: friendly trade-off explanation (advisory only — never auto-accepts).
1355        if (recommendation.tradeoff) {
1356          var tradeoff = document.createElement('p');
1357          tradeoff.className = 'fg-chat-offer-card__recommendation-tradeoff';
1358          tradeoff.textContent = recommendation.tradeoff;
1359          card.appendChild(tradeoff);
1360        }
1361      }
1362
1363      // Go is fixed-offer: a seller accepts or passes, so there is no buyer-side
1364      // accept/reject-counter here. Only the (legacy) payment CTA can appear.
1365      var actions = document.createElement('div');
1366      actions.className = 'fg-chat-offer-card__actions';
1367      if (offer.needsPayment) {
1368        var pay = document.createElement('button');
1369        pay.type = 'button';
1370        pay.className = 'fg-chat-offer-card__btn fg-chat-offer-card__btn--primary';
1371        pay.textContent = 'Pay to Start';
1372        pay.setAttribute('data-chat-offer-action', 'pay');
1373        pay.setAttribute('data-request-id', offer.broadcastRequestId);
1374        actions.appendChild(pay);
1375      }
1376      if (actions.childNodes.length) card.appendChild(actions);
1377      row.appendChild(card);
1378      if (log) log.scrollTop = log.scrollHeight;
1379      persistChatState();
1380    }
1381
1382    // Phase 19E-Fix2: once payment is no longer pending, every Pay CTA in the
1383    // transcript (offer-card buttons and action chips) is stale — remove them so
1384    // "Pay to Start" can never sit next to "Payment authorized".
1385    function clearStalePaymentCtas() {
1386      if (!log) return;
1387      Array.prototype.forEach.call(log.querySelectorAll('[data-chat-offer-action="pay"], [data-chat-action="pay"]'), function (btn) {
1388        if (btn.parentNode) btn.parentNode.removeChild(btn);
1389      });
1390      persistChatState();
1391    }
1392
1393    function applyBuyerUiState(uiState) {
1394      if (!uiState) return;
1395      if (!uiState.showPaymentCta) clearStalePaymentCtas();
1396
1397      // Canonical timeline copy — appended at most once. Covers escrow's
1398      // "Payment authorized · preparing" and the "marked delivered · confirm"
1399      // states so the chat never contradicts the backend (e.g. asking to
1400      // authorize a hold that's already in place).
1401      var stage = uiState.stage;
1402      if (uiState.timelineCopy && stage
1403          && stage !== 'draft' && stage !== 'awaiting_offers' && stage !== 'offer_received') {
1404        appendAssistantOnce(uiState.timelineCopy);
1405      }
1406
1407      // The backend's primaryCta is authoritative — only surface the actions it
1408      // sanctions. Never invent a payment step it didn't ask for.
1409      var cta = uiState.primaryCta;
1410      if (cta && cta.action === 'pay' && uiState.showPaymentCta) {
1411        renderPaymentActions(createdRequestId);
1412      } else if (cta && cta.action === 'confirm_delivery') {
1413        renderChatActions([
1414          { action: 'confirm_delivery', label: cta.label || 'Confirm receipt', kind: 'primary', value: createdRequestId },
1415          { action: 'report_delivery_problem', label: 'Report a problem', kind: 'basic', value: createdRequestId },
1416          { action: 'view_request', label: 'View request', kind: 'basic', value: createdRequestId }
1417        ]);
1418      } else if (cta && cta.action === 'rate_experience') {
1419        renderChatActions([
1420          { action: 'rate_experience', label: cta.label || 'Rate this experience', kind: 'primary', value: createdRequestId },
1421          { action: 'start_new_craving', label: 'Start new craving', kind: 'basic' },
1422          { action: 'view_request', label: 'View request', kind: 'basic', value: createdRequestId }
1423        ]);
1424      }
1425    }
1426
1427    // Append an assistant status line at most once per transcript — restores and
1428    // refreshes must never stack duplicate "Payment authorized…" bubbles. Returns
1429    // the row (existing or new) so callers can attach cards to the right bubble.
1430    function appendAssistantOnce(text) {
1431      if (!log || !text) return null;
1432      var bubbles = log.querySelectorAll('.fg-chat-msg--assistant .fg-c
1432hat-bubble');
1433      for (var i = 0; i < bubbles.length; i += 1) {
1434        if (bubbles[i].textContent === text) {
1435          return bubbles[i].closest ? bubbles[i].closest('.fg-chat-msg--assistant') : bubbles[i].parentNode;
1436        }
1437      }
1438      return appendMessage('assistant', text);
1439    }
1440
1441    function fetchAndRenderOffer(requestId, offerId) {
1442      if (!requestId) return;
1443      requestJson('/broadcast-requests/' + requestId + '/offers', { method: 'GET' }).then(function (json) {
1444        var offers = json.buyerOffers || json.offers || [];
1445        var offer = null;
1446        offers.some(function (candidate) {
1447          if (offerId && String(candidate._id) === String(offerId)) { offer = candidate; return true; }
1448          return false;
1449        });
1450        if (!offer) {
1451          offer = offers.find(function (candidate) {
1452            return candidate && candidate.responseStatus === 'accepted_original';
1453          });
1454        }
1455        if (offer) renderOfferCard(offer, json.recommendation, { paymentNeeded: !!(json.uiState && json.uiState.showPaymentCta) });
1456        // Roadmap D3: while still waiting (no accepted offer), narrate pass
1457        // activity honestly. appendAssistantOnce dedupes; the text changes only
1458        // when the count grows, so each milestone lands once.
1459        if (!offer && json.passSummary && json.passSummary.passed > 0 && !chatClosed) {
1460          var passReasonLabels = { too_far: 'too far away', dont_have_item: 'they don\u2019t have the item', too_busy: 'busy right now', price_too_low: 'the offer felt low' };
1461          var passedN = json.passSummary.passed;
1462          var passLine = passedN + (passedN === 1 ? ' seller has' : ' sellers have') + ' passed'
1463            + (passReasonLabels[json.passSummary.topReason] ? ' (most said: ' + passReasonLabels[json.passSummary.topReason] + ')' : '')
1464            + ' \u2014 your request is still out to other sellers nearby.';
1465          appendAssistantOnce(passLine);
1466        }
1467        // Phase 19E: keep the live progress card in sync with real states.
1468        if (json.progress) renderBuyerProgress(json.progress, json.expiry);
1469        // Phase 19E-Fix2: canonical state decides whether any payment CTA may live.
1470        applyBuyerUiState(json.uiState);
1471      }).catch(function () {
1472        toast('A seller responded. Open the request to review it.', 'info', '/broadcast-requests/' + requestId);
1473      });
1474    }
1475
1476    // Phase 19D: buyer<->accepted-seller transaction relay INSIDE the Craving Go
1477    // chat. Seller and system messages append to the timeline; the buyer replies
1478    // through a dedicated relay composer — every send is an explicit tap, and
1479    // Craving Go itself never authors a buyer or seller message.
1480    var relayComposer = document.getElementById('fgChatRelayComposer');
1481    var relayForm = document.getElementById('fgChatRelayForm');
1482    var relayInput = document.getElementById('fgChatRelayInput');
1483    var relayQuickWrap = document.getElementById('fgChatRelayQuickReplies');
1484    var relayFetchPending = false;
1485    // Segmented switch state: who the relay composer is addressing right now.
1486    var relayTarget = 'seller'; // 'seller' | 'go'
1487    var relaySellerName = '';
1488    // Lifecycle events the chat already narrates natively (send confirmation,
1489    // offer cards, payment CTA) — skip their relay duplicates in this surface.
1490    var CHAT_NARRATED_RELAY_EVENTS = ['request_sent', 'offer_received', 'payment_needed'];
1491
1492    function appendChatRelayMessage(msg) {
1493      if (!log || !msg || !msg._id) return false;
1494      if (log.querySelector('[data-chat-relay-id="' + msg._id + '"]')) return false;
1495      var isSystem = msg.senderRole === 'system' || msg.senderRole === 'craving_go';
1496      if (isSystem && msg.eventKey && CHAT_NARRATED_RELAY_EVENTS.indexOf(String(msg.eventKey).split(':')[0]) !== -1) {
1497        return false;
1498      }
1499      var kind = isSystem ? 'system' : (msg.mine ? 'user' : 'seller');
1500      var row = document.createElement('div');
1501      row.className = 'fg-chat-msg fg-chat-msg--' + kind;
1502      row.setAttribute('data-chat-relay-id', msg._id);
1503      // Group-chat: the seller appears as a real participant with their photo.
1504      if (kind === 'seller') row.appendChild(createParticipantAvatar(msg.
1504senderAvatar));
1505      var bubble = document.createElement('div');
1506      bubble.className = 'fg-chat-bubble' + (kind === 'seller' ? ' fg-chat-bubble--seller' : (kind === 'system' ? ' fg-chat-bubble--system' : ''));
1507      if (kind === 'seller') {
1508        var name = document.createElement('strong');
1509        name.className = 'fg-chat-bubble__name';
1510        name.textContent = msg.senderName || 'Seller';
1511        bubble.appendChild(name);
1512      }
1513      var body = document.createElement('span');
1514      body.textContent = msg.body || '';
1515      bubble.appendChild(body);
1516      // Timestamp so the buyer can see when each message was sent (system lines
1517      // stay time-free — they're narration, not part of the conversation).
1518      if (!isSystem && msg.createdAt) {
1519        var time = document.createElement('span');
1520        time.className = 'fg-chat-time';
1521        time.textContent = formatChatTime(msg.createdAt);
1522        bubble.appendChild(time);
1523      }
1524      row.appendChild(bubble);
1525      log.appendChild(row);
1526      refreshMessageGrouping();
1527      // substitution — advisory copy only; the buyer still decides and replies.
1528      if (kind === 'seller' && msg.quickReplyKey === 'seller_substitution') {
1529        appendMessage('assistant', 'The seller is asking about a substitution. You can reply below, or cancel if your request still allows it.');
1530      }
1531      log.scrollTop = log.scrollHeight;
1532      persistChatState();
1533      return true;
1534    }
1535
1536    function renderChatRelayQuickReplies(replies) {
1537      if (!relayQuickWrap) return;
1538      relayQuickWrap.innerHTML = '';
1539      (replies || []).forEach(function (reply) {
1540        var btn = document.createElement('button');
1541        btn.type = 'button';
1542        btn.className = 'fg-chat-relay-quick__btn';
1543        btn.textContent = reply.text;
1544        btn.addEventListener('click', function () {
1545          postChatRelay({ quickReplyKey: reply.key }, btn);
1546        });
1547        relayQuickWrap.appendChild(btn);
1548      });
1549    }
1550
1551    // Phase 19E-Fix2: ONE primary composer at a time. While the relay is active
1552    // the seller composer is primary and the Craving Go assistant input hides
1553    // behind an explicit "Ask Craving Go" toggle — never two send buttons.
1554    var chatAssistantForm = document.getElementById('fgChatForm');
1555    var askCravingToggle = document.getElementById('fgChatAskCravingToggle');
1556
1557    function applyComposerVisibility(relayActive) {
1558      if (!chatAssistantForm) return;
1559      if (!relayActive) {
1560        chatAssistantForm.hidden = false;
1561        if (relayComposer) relayComposer.hidden = true;
1562        if (askCravingToggle) askCravingToggle.hidden = true;
1563        return;
1564      }
1565      // Group model: ONE composer with a segmented switch that picks the target —
1566      // Seller (relay) or Go (assistant). The legacy "@Go" chip is retired by it.
1567      chatAssistantForm.hidden = true;
1568      if (relayComposer) relayComposer.hidden = false;
1569      if (askCravingToggle) askCravingToggle.hidden = true;
1570    }
1571
1572    // --- Segmented Go/seller switch ---------------------------------------
1573    var targetSwitch = document.getElementById('fgChatTargetSwitch');
1574    var targetSellerBtn = document.getElementById('fgChatTargetSeller');
1575    var targetGoBtn = document.getElementById('fgChatTargetGo');
1576    var targetSellerNameEl = document.getElementById('fgChatTargetSellerName');
1577    var targetHint = document.getElementById('fgChatTargetHint');
1578
1579    function applyRelayTarget() {
1580      var toGo = relayTarget === 'go';
1581      if (targetSellerBtn) {
1582        targetSellerBtn.classList.toggle('is-active', !toGo);
1583        targetSellerBtn.setAttribute('aria-selected', String(!toGo));
1584      }
1585      if (targetGoBtn) {
1586        targetGoBtn.classList.toggle('is-active', toGo);
1587        targetGoBtn.setAttribute('aria-selected', String(toGo));
1588      }
1589      if (targetSwitch) targetSwitch.classList.toggle('fg-chat-target-switch--go', toGo);
1590      var who = relaySellerName || 'the seller';
1591      if (relayInput) {
1592        relayInput.setAttribute('placeholder', toGo ? 'Ask ' + assistantName + ' anything…' : 'Message ' + who + '…');
1593      }
1594      if (targetHint) {
1595        targetHint.hidden = false;
1596        targetHint.textContent = toGo
1597          ? assistantName + ' helps you — the seller won\'t see this.'
1598          : 'This goes to ' + who + ', not ' + assistantName + '.';
1599      }
1600    }
1601    function setRelayTarget(target) {
1602      relayTarget = target === 'go' ? 'go' : 'seller';
1603      applyRelayTarget();
1604      if (relayInput) { try { relayInput.focus(); } catch (e) {} }
1605    }
1606    if (targetSellerBtn) targetSellerBtn.addEventListener('click', function () { setRelayTarget('seller'); });
1607    if (targetGoBtn) targetGoBtn.addEventListener('click', function () { setRelayTarget('go'); });
1608
1609    // Group-chat: when the seller joins, turn the 1:1 Go header into a small
1610    // group (stacked avatars + "You, <seller> & Go") so it's unmistakable who's
1611    // in the conversation. Runs once.
1612    var groupHeaderApplied = false;
1613    function applyGroupHeader(relay) {
1614      if (groupHeaderApplied || !relay || !relay.sellerName) return;
1615      groupHeaderApplied = true;
1616      var avatarsWrap = document.getElementById('fgCgHeaderAvatars');
1617      var titleEl = document.getElementById('fgCgHeaderTitle');
1618      var statusText = document.getElementById('fgCgHeaderStatusText');
1619      if (avatarsWrap) {
1620        avatarsWrap.classList.add('fg-cg-chat-header__avatar-wrap--group');
1621        var img = document.createElement('img');
1622        img.className = 'fg-cg-chat-header__avatar fg-cg-chat-header__avatar--seller';
1623        img.src = relay.sellerAvatar || '/images/default-avatar.png';
1624        img.alt = '';
1625        img.addEventListener('error', function () { img.src = '/images/default-avatar.png'; });
1626        avatarsWrap.appendChild(img);
1627      }
1628      if (titleEl) titleEl.textContent = 'You, ' + relay.sellerName + ' & ' + assistantName;
1629      if (statusText) statusText.textContent = relay.sellerName + ' is in this chat';
1630    }
1631
1632    function setChatRelayComposer(relay) {
1633      if (!relayComposer) return;
1634      var canPost = !!(relay && relay.canPost);
1635      relayComposer.hidden = !canPost;
1636      if (canPost) {
1637        // The group header (stacked avatars + "You, X & Go") is the persistent,
1638        // always-in-order signal that the seller is in the chat. We intentionally
1639        // do NOT append a separate "joined" card — on a restored/late transcript
1640        // it lands at the bottom (after delivery) and reads out of order. The
1641        // chronological accept moment is the "X accepted your offer" announcement.
1642        applyGroupHeader(relay);
1643        renderChatRelayQuickReplies(relay.quickReplies || []);
1644        relaySellerName = relay.sellerName || '';
1645        if (targetSellerNameEl) targetSellerNameEl.textContent = relaySellerName || 'Seller';
1646        // The segmented switch owns the seller name, placeholder + hint; (re)apply.
1647        applyRelayTarget();
1648      }
1649      applyComposerVisibility(canPost);
1650    }
1651
1652    function fetchChatRelay() {
1653      if (!createdRequestId || relayFetchPending) return;
1654      relayFetchPending = true;
1655      requestJson('/broadcast-requests/' + createdRequestId + '/messages', { method: 'GET' }).then(function (json) {
1656        var appendedTheirs = false;
1657        (json.messages || []).forEach(function (msg) {
1658          var appended = appendChatRelayMessage(msg);
1659          if (appended && !msg.mine) appendedTheirs = true;
1660        });
1661        setChatRelayComposer(json.relay || {});
1662        if (appendedTheirs) {
1663          requestJson('/broadcast-requests/' + createdRequestId + '/messages/read', {
1664            method: 'POST',
1665            body: JSON.stringify({}),
1666          }).catch(function () {});
1667        }
1668      }).catch(function () {
1669        // Relay is additive — chat keeps working even if the thread fetch fails.
1670      }).finally(function () {
1671        relayFetchPending = false;
1672      });
1673    }
1674
1675    // Phase 19E: single live progress card pinned to the bottom of the chat —
1676    // real states only (waiting / seller reviewing / offers / payment), with a
1677    // ticking countdown and the one bounded "Extend" action when available.
1678    function renderBuyerProgress(progress, expiryInfo) {
1679      if (!log || !createdRequestId || !progress || !progress.message) return;
1680      var card = document.getElementById('fgChatProgressCard');
1681      if (!card) {
1682        card = document.createElement('div');
1683        card.id = 'fgChatProgressCard';
1684        card.className = 'fg-chat-progress';
1685        log.appendChild(card);
1686      } else if (card !== log.lastElementChild) {
1687        log.appendChild(card);
1688      }
1689      card.innerHTML = '';
1690      if (progress.stage === 'waiting' || progress.stage === 'seller_reviewing') {
1691        var dot = document.createElement('span');
1692        dot.className = 'fg-waiting-dot';
1693        dot.setAttribute('aria-hidden', 'true');
1694        card.appendChild(dot);
1695      }
1696      var msg = document.createElement('span');
1697      msg.className = 'fg-chat-progress__msg';
1698      msg.textContent = progress.message;
1699      card.appendChild(msg);
1700      var showCountdown = expiryInfo && expiryInfo.expiresAt && !expiryInfo.expired
1701        && ['waiting', 'seller_reviewing', 'offer_received', 'offers_received'].indexOf(progress.stage) !== -1;
1702      if (showCountdown) {
1703        var chip = document.createElement('span');
1704        chip.className = 'fg-expiry-chip';
1705        chip.setAttribute('data-expires-at', expiryInfo.expiresAt);
1706        chip.setAttribute('data-expiry-scope', 'request');
1707        var chipText = document.createTextNode('Expires in ');
1708        var chipTime = document.createElement('span');
1709        chipTime.className = 'fg-expiry-chip__time';
1710        chip.appendChild(chipText);
1711        chip.appendChild(chipTime);
1712        card.appendChild(chip);
1713        if (expiryInfo.canExtend) {
1714          var ext = document.createElement('button');
1715          ext.type = 'button';
1716          ext.className = 'fg-chat-progress__extend';
1717          ext.textContent = 'Extend by ' + (expiryInfo.extensionMinutes || 5) + ' min';
1718          ext.addEventListener('click', function () {
1719            setBusy(ext, true);
1720            requestJson('/broadcast-requests/' + createdRequestId + '/extend', {
1721              method: 'POST',
1722              body: JSON.stringify({}),
1723            }).then(function (json) {
1724              if (json && json.expiry) applyRequestExpiryUpdate(json.expiry);
1725              toast('Extended — sellers have a few more minutes.', 'success');
1726              if (ext.parentNode) ext.parentNode.removeChild(ext);
1727              fetchChatRelay();
1728            }).catch(function (err) {
1729              toast(err.message, 'error');
1730            }).finally(function () {
1731              setBusy(ext, false);
1732            });
1733          });
1734          card.appendChild(ext);
1735        }
1736      }
1737      log.scrollTop = log.scrollHeight;
1738      persistChatState();
1739    }
1740
1741    function postChatRelay(body, busyEl) {
1742      if (!createdRequestId) return;
1743      var el = busyEl || document.getElementById('fgChatRelaySendBtn');
1744      setBusy(el, true);
1745      requestJson('/broadcast-requests/' + createdRequestId + '/messages', {
1746        method: 'POST',
1747        body: JSON.stringify(body),
1748      }).then(function (json) {
1749        if (json && json.message) appendChatRelayMessage(json.message);
1750        if (relayInput) relayInput.value = '';
1751      }).catch(function (err) {
1752        toast(err.message, 'error');
1753      }).finally(function () {
1754        setBusy(el, false);
1755      });
1756    }
1757
1758    if (relayForm) {
1759      relayForm.addEventListener('submit', function (event) {
1760        event.preventDefault();
1761        var text = relayInput ? String(relayInput.value || '').trim() : '';
1762        if (!text) return;
1763        // The segmented switch picks the target. "@Go ..." stays a power-user
1764        // shortcut that routes to the assistant regardless of the active segment.
1765        var mention = text.match(/^@go\b[\s,:]*/i);
1766        var toGo = relayTarget === 'go' || !!mention;
1767        if (toGo) {
1768          var goText = mention ? text.slice(mention[0].length).trim() : text;
1769          if (relayInput) relayInput.value = '';
1770          if (goText) send(goText);
1771          return;
1772        }
1773        postChatRelay({ message: text });
1774      });
1775    }
1776
1777    document.addEventListener('fg:relay-message-event', function () {
1778      fetchChatRelay();
1779    });
1780
1781    // Phase 19A: in-chat action chips (Confirm / Edit / Send / View / Change …)
1782    // attached to the assistant message. Every chip maps to an existing, validated
1783    // route or a structured edit action — the client never sets buyer/price/status.
1784    function renderChatActions(actions) {
1785      var row = lastAssistantRow();
1786      var list = (actions || []).filter(function (a) {
1787        // never re-offer Confirm once a request already exists
1788        return !(createdRequestId && a.action === 'confirm');
1789      });
1790      if (!row || !list.length) return;
1791      actions = list;
1792      // Any new actionable turn invalidates earlier action groups.
1793      invalidatePriorActionGroups();
1794      var wrap = document.createElement('div');
1795      wrap.className = 'fg-chat-action-chips';
1796      wrap.setAttribute('data-action-seq', String(appliedSeq));
1797      actions.forEach(function (a) {
1798        var el = document.createElement('button');
1799        el.type = 'button';
1800        el.className = 'fg-chat-action-chip fg-chat-action-chip--' + (a.kind || 'basic');
1801        el.textContent = a.label || a.action;
1802        el.setAttribute('data-chat-action', a.action);
1803        el.addEventListener('click', function () {
1804          if (wrap.classList.contains('is-stale')) return;
1805          Array.prototype.forEach.call(wrap.querySelectorAll('button'), function (b) { b.disabled = true; });
1806          var label = a.label || a.value || a.action;
1807          if (a.action === 'set_unit_type' || a.action === 'accept_pack' || a.action === 'keep_pieces'
1808              || /by the piece|by weight \(kg\)|whole pack|keep asking for/i.test(String(label))) {
1809            send(a.value || label);
1810            return;
1811          }
1812          handleChatAction(a.action, el, wrap, a.value);
1813        });
1814        wrap.appendChild(el);
1815      });
1816      row.appendChild(wrap);
1817      actionGroups.push(wrap);
1818      if (log) log.scrollTop = log.scrollHeight;
1819      persistChatState();
1820    }
1821
1822    function handleChatAction(action, el, wrap, value) {
1823      if (action === 'confirm') return doConfirm(wrap);
1824      if (action === 'send') return doBroadcast(wrap);
1825      if (action === 'pay') {
1826        var paymentRequestId = value || createdRequestId;
1827        if (paymentRequestId) window.location.href = '/broadcast-requests/' + paymentRequestId;
1828        return;
1829      }
1830      if (action === 'view') {
1831        showRequestDetails();
1832        return;
1833      }
1834      // Phase 32-Fix: confirm receipt straight from the chat. For escrow this is
1835      // what captures the held payment and transfers the seller's share — the
1836      // buyer no longer has to leave the conversation to release it.
1837      if (action === 'confirm_delivery') {
1838        return confirmDeliveryFromChat(value || createdRequestId, wrap);
1839      }
1840      if (action === 'rate_experience') {
1841        var rateId = value || createdRequestId;
1842        if (rateId) window.location.href = '/broadcast-requests/' + rateId + '/review';
1843        return;
1844      }
1845      if (action === 'report_delivery_problem') {
1846        return reportDeliveryProblemFromChat(value || createdRequestId, wrap);
1847      }
1848      // Phase 19A-Fix10.4 — session lifecycle actions.
1849      if (action === 'start_new_craving') {
1850        requestStartNewCraving();
1851        return;
1852      }
1853      // Confirm step after the live-order guard below — the buyer explicitly chose
1854      // to start fresh while an order is live; their order stays open elsewhere.
1855      if (action === 'start_new_craving_confirm') {
1856        startNewCraving();
1857        return;
1858      }
1859      if (action === 'go_back' || action === 'keep_request') {
1860        appendMessage('assistant', action === 'keep_request'
1861          ? 'Okay — your order stays open. You can keep chatting with the seller below.'
1862          : 'Okay — tell me what you’re craving.');
1863        if (input) { try { input.focus(); } catch (e) {} }
1864        return;
1865      }
1866      // Phase 28: a reorder ("the usual") chip sends the item name as a normal
1867      // message, reusing the full item-parse + eligibility flow.
1868      if (action === 'reorder') {
1869        if (value) return send(value);
1870        return;
1871      }
1872      // A suggested-price chip sends the number as a normal message — the buyer
1873      // is at the price step, so it's parsed as the offer price.
1874      if (action === 'set_price') {
1875        if (value) return postTurn({ actionType: 'set_price', value: value }, 'Offer $' + value);
1876        return;
1877      }
1878      if (action === 'set_supply_type') {
1879        var supplyLabel = value === 'homemade' ? 'Homemade' : 'Store-bought';
1880        return postTurn({ actionType: 'set_supply_type', value: value || '' }, supplyLabel);
1881      }
1882      if (action === 'set_unit_type') {
1883        var unitLabel = value === 'kg' ? 'By weight (kg)' : 'By the piece';
1884        return postTurn({ actionType: 'set_unit_type', value: value || 'piece', message: unitLabel }, unitLabel);
1885      }
1886      if (action === 'accept_pack') {
1887        return postTurn({ actionType: 'accept_pack', message: 'Add this whole pack' }, 'Add this whole pack');
1888      }
1889      if (action === 'keep_pieces') {
1890        return postTurn({ actionType: 'keep_pieces', value: value, message: 'Keep asking for pieces' }, 'Keep asking for pieces');
1891      }
1892      // Per-line basket edits: target ONE item in the basket.
1893      if (action === 'edit_basket_line') return postTurn({ actionType: 'edit_basket_line', value: value }, 'Edit item');
1894      if (action === 'edit_basket_line_qty') return postTurn({ actionType: 'edit_basket_line_qty', value: value }, 'Change quantity');
1895      if (action === 'remove_basket_line') return postTurn({ actionType: 'remove_basket_line', value: value }, 'Remove item');
1896      // Phase 26: one-tap fulfillment switch (e.g. nearby sellers only do pickup).
1897      if (action === 'set_fulfillment') {
1898        if (value) return postTurn({ actionType: 'set_fulfillment', value: value }, 'Switch to ' + value);
1899        return;
1900      }
1901      if (action === 'cancel_craving') {
1902        return postTurn({ actionType: 'cancel_craving' }, 'Cancel');
1903      }
1904      if (action === 'keep_request') {
1905        return postTurn({ actionType: 'keep_request' }, 'Keep request open');
1906      }
1907      if (action === 'view_request') {
1908        var viewId = value || createdRequestId;
1909        if (viewId) { window.location.href = '/broadcast-requests/' + viewId + '?returnTo=craving-chat'; }
1910        else { showRequestDetails(); }
1911        return;
1912      }
1913      if (action === 'view_request_history') {
1914        window.location.href = '/broadcast-requests/mine';
1915        return;
1916      }
1917      if (action === 'cancel_request_confirm') {
1918        return cancelBroadcastRequest(value || createdRequestId, el, wrap);
1919      }
1920      if (action === 'browse_products') {
1921        window.location.href = '/products';
1922        return;
1923      }
1924      if (action === 'add_location') {
1925        window.location.href = '/account';
1926        return;
1927      }
1928      if (action === 'try_later') {
1929        appendMessage('assistant', 'Okay. Try again later when more nearby sellers are available.');
1930        return;
1931      }
1932      if (action === 'notify_sellers_available') {
1933        appendMessage('assistant', 'Notify-me is coming soon.');
1934        return;
1935      }
1936      if (action === 'save_craving_later') {
1937        appendMessage('assistant', 'Save-for-later is coming soon. Your current chat details will stay here while this page is open.');
1938        return;
1939      }
1940      // Structured edit actions — the backend owns the edit state machine. After a
1941      // request already exists (post-confirm) the session is closed, so just guide.
1942      if (action === 'edit') {
1943        if (createdRequestId) {
1944          appendMessage('assistant', 'This craving is ready to send. To change it, start a new craving.');
1945          return;
1946        }
1947        return postTurn({ actionType: 'edit_request' }, 'Edit');
1948      }
1949      if (action === 'change_item') return postTurn({ actionType: 'edit_item' }, 'Change item');
1950      if (action === 'change_quantity') return postTurn({ actionType: 'edit_quantity' }, 'Change quantity');
1951      if (action === 'confirm_typo') return postTurn({ actionType: 'confirm_typo', value: value }, (el && el.textContent) || 'Yes');
1952      if (action === 'reject_typo') return postTurn({ actionType: 'reject_typo' }, 'No, I meant something else');
1953      if (action === 'change_offer') return postTurn({ actionType: 'edit_price' }, 'Change offer');
1954      if (action === 'change_time') return postTurn({ actionType: 'edit_time' }, 'Change time');
1955      if (action === 'start_over') return postTurn({ actionType: '
1955start_over' }, 'Start over');
1956      if (action === 'change_item_yes') return postTurn({ actionType: 'change_item_yes', value: value }, 'Yes, change item');
1957      if (action === 'keep_current') return postTurn({ actionType: 'keep_current' }, 'No, keep current request');
1958      if (action === 'keep_low_offer') return postTurn({ actionType: 'keep_low_offer' }, 'Keep offer');
1959      if (action === 'keep_basket_offer') return postTurn({ actionType: 'keep_basket_offer' }, 'Keep offer');
1960      if (action === 'change_low_offer') return postTurn({ actionType: 'change_low_offer' }, 'Change offer');
1961      if (action === 'offer_listed_price') return postTurn({ actionType: 'offer_listed_price' }, 'Offer listed price');
1962      if (action === 'enter_own_offer') return postTurn({ actionType: 'enter_own_offer' }, 'Enter my own offer');
1963      if (action === 'any_remaining_brand') {
1964        return postTurn({ actionType: 'any_remaining_brand' }, 'Any brand for all remaining items?');
1965      }
1966      return postTurn({
1967        actionType: action,
1968        value: value,
1969        message: (el && el.textContent) || String(value || action),
1970      }, (el && el.textContent) || String(value || action));
1971    }
1972
1973    function renderDraft(draft) {
1974      var list = document.getElementById('fgChatDraftList');
1975      if (!list) return;
1976      list.innerHTML = '';
1977      var lineItems = draft && Array.isArray(draft.lineItems) ? draft.lineItems.filter(function (item) {
1978        return item && item.itemName;
1979      }) : [];
1980      var isBasket = lineItems.length > 1;
1981      var keys = DRAFT_ORDER.filter(function (k) {
1982        if (isBasket && (k === 'itemName' || k === 'quantity')) return false;
1983        return draft && draft[k] !== undefined && draft[k] !== null && draft[k] !== '';
1984      });
1985      if (!keys.length && !isBasket) {
1986        list.innerHTML = '<li class="fg-chat-empty">Nothing yet — tell me what you need.</li>';
1987        return;
1988      }
1989      if (isBasket) {
1990        var basketLi = document.createElement('li');
1991        var basketLabel = document.createElement('span');
1992        basketLabel.className = 'fg-chat-draft-label';
1993        basketLabel.textContent = 'Items';
1994        var basketValue = document.createElement('span');
1995        basketValue.className = 'fg-chat-draft-value';
1996        basketValue.textContent = lineItems.map(function (item) {
1997          return item.itemName + ' x' + (item.quantity || '?');
1998        }).join('\n');
1999        basketLi.appendChild(basketLabel);
2000        basketLi.appendChild(basketValue);
2001        list.appendChild(basketLi);
2002      }
2003      keys.forEach(function (k) {
2004        var li = document.createElement('li');
2005        var label = document.createElement('span');
2006        label.className = 'fg-chat-draft-label';
2007        label.textContent = DRAFT_LABELS[k] || k;
2008        var value = document.createElement('span');
2009        value.className = 'fg-chat-draft-value';
2010        var v = draft[k];
2011        if (k === 'originalOfferedPrice') v = (draft.currency ? draft.currency + ' ' : '') + '$' + v;
2012        value.textContent = v;
2013        li.appendChild(label);
2014        li.appendChild(value);
2015        list.appendChild(li);
2016      });
2017    }
2018
2019    function renderMissing(missing) {
2020      var box = document.getElementById('fgChatMissing');
2021      var list = document.getElementById('fgChatMissingList');
2022      if (!box || !list) return;
2023      if (!missing || !missing.length) {
2024        box.hidden = true;
2025        list.innerHTML = '';
2026        return;
2027      }
2028      box.hidden = false;
2029      list.innerHTML = '';
2030      missing.forEach(function (m) {
2031        var li = document.createElement('li');
2032        li.textContent = fieldLabels[m] || m;
2033        list.appendChild(li);
2034      });
2035    }
2036
2037    function renderChoices(json) {
2038      var box = document.getElementById('fgChatChoices');
2039      var title = document.getElementById('fgChatChoicesTitle');
2040      var buttons = document.getElementById('fgChatChoiceButtons');
2041      var helper = document.getElementById('fgChatHelperCopy');
2042      var options = [];
2043      if (json.requiresEditChoice && json.editChoices && json.editChoices.length) {
2044        options = json.editChoices;
2045      } else if (json.requiresUserChoice && json.choices && json.choices.length) {
2046        options = json.choices;
2047      }
2048      if (helper) {
2049        if (json.helperCopy && (json.conversationMode === 'idle' || json.requiresEditChoice)) {
2050          helper.hidden = false;
2051          helper.textContent = json.helperCopy;
2052        } else if (!options.length) {
2053          helper.hidden = true;
2054          helper.textContent = '';
2055        }
2056      }
2057      if (!box || !buttons) return;
2058      if (!options.length) {
2059        box.hidden = true;
2060        buttons.innerHTML = '';
2061        return;
2062      }
2063      box.hidden = false;
2064      if (title) {
2065        var choiceTitle = json.choicePrompt || (json.requiresEditChoice ? 'What would you like to change?' : 'Choose one');
2066        var duplicatesAssistant = json.choicePrompt && json.assistantMessage
2067          && String(json.choicePrompt).trim() === String(json.assistantMessage).trim();
2068        title.hidden = !!duplicatesAssistant;
2069        title.textContent = duplicatesAssistant ? '' : choiceTitle;
2070      }
2071      buttons.innerHTML = '';
2072      options.forEach(function (choice) {
2073        var btn = document.createElement('button');
2074        btn.type = 'button';
2075        btn.className = 'fg-chat-choice-btn';
2076        btn.textContent = choice;
2077        btn.addEventListener('click', function () {
2078          send(choice);
2079        });
2080        buttons.appendChild(btn);
2081      });
2082    }
2083
2084    function applyState(json) {
2085      lastDraft = json.draft || {};
2086      if (json.linePriceGuide) lastLinePriceGuide = json.linePriceGuide;
2087      renderDraft(lastDraft);
2088      renderMissing(json.missingFields);
2089      renderChoices(json);
2090      if (json.conversationIntent === 'reject_confirmation' || json.requiresEditChoice) {
2091        confirmationRejected = true;
2092      } else if (json.readyToConfirm || json.readyForConfirmButton) {
2093        confirmationRejected = false;
2094      }
2095      if (confirmBtn) {
2096        var isIdle = json.conversationMode === 'idle';
2097        var confirmReady = !!(json.readyToConfirm || json.readyForConfirmButton);
2098        confirmBtn.hidden = isIdle || !confirmReady || !!createdRequestId || confirmationRejected || json.requiresUserChoice || json.requiresEditChoice;
2099      }
2100      if (broadcastBtn && json.conversationMode === 'idle') {
2101        broadcastBtn.hidden = true;
2102      }
2103      if (json.aiAvailable === false && manualBtn) {
2104        manualBtn.hidden = false;
2105        showFallbackActions();
2106        pageMessage('The assistant is unavailable right now. You can finish with the quick form — your details are kept.', 'info');
2107      }
2108    }
2109
2110    // Core turn poster. `bodyObj` is the JSON sent to the server; `userBubbleText`
2111    // is shown in the transcript (may differ from the structured payload).
2112    function postTurn(bodyObj, userBubbleText) {
2113      if (pending) return Promise.resolve(); // ignore double-submit while in flight
2114      var url = sessionId
2115        ? '/broadcast-requests/conversation/' + sessionId + '/message'
2116        : '/broadcast-requests/conversation/start';
2117      var myReq = ++reqSeq;
2118      pending = true;
2119      root.classList.add('is-pending');
2120      setBusy(sendBtn, true);
2121      if (input) input.disabled = true;
2122      if (userBubbleText) appendMessage('user', userBubbleText);
2123      var typing = appendTypingIndicator();
2124      return requestJson(url, {
2125        method: 'POST',
2126        body: JSON.stringify(bodyObj),
2127      }).then(function (json) {
2128        removeTypingIndicator(typing);
2129        // Ignore stale/out-of-order responses (a newer request already applied).
2130        if (myReq < appliedSeq) return;
2131        appliedSeq = myReq;
2132        // Phase 19A-Fix4: a new turn applied → any earlier in-chat action buttons are
2133        // now stale and must not stay clickable.
2134        invalidatePriorActionGroups();
2135        // Phase 19A-Fix5: collapse the previous product-card group once a new turn lands.
2136        collapsePriorCardGroups();
2137        if (json.session && json.session._id) sessionId = json.session._id;
2138        var reviewReady = showsReviewCard(json);
2139        // Phase 19A-Fix5: don't render the exact same assistant prompt twice in a row.
2140        if (json.assistantMessage && json.assistantMessage !== lastAssistantText) {
2141          // On a review turn the styled card replaces the plain "Review this…" summary.
2142          var displayText = reviewReady ? stripReviewSummary(json.assistantMessage) : json.assistantMessage;
2143          if (displayText) appendMessage('assistant', displayText);
2144          else if (reviewReady) appendMessage('assistant', 'Here’s your craving — please review:');
2145          lastAssistantText = json.assistantMessage;
2146        }
2147        applyState(json);
2148        // Friendly current-step header.
2149        setConversationState(json.conversationState);
2150        if (json.inputPlaceholder && input) input.setAttribute('placeholder', json.inputPlaceholder);
2151        // Phase 19A-Fix10.4: a cleared draft (cancel/start-over) must drop any stale
2152        // review/basket card so old items (e.g. a "Psopsi x10" typo) can't linger.
2153        if (json.draftCleared) {
2154          removeReviewCards();
2155          lastDraft = {};
2156          lastLinePriceGuide = null;
2157          renderDraft({});
2158        }
2159        if (typeof json.lifecycleState !== 'undefined') lifecycleState = json.lifecycleState;
2160        // Mark the chat read-only when the backend says the session/request is closed.
2161        if (json.sessionClosed || isTerminalLifecycle(json.lifecycleState)) {
2162          setChatClosed(true);
2163        }
2164        // Progressive basket: refresh Your basket after each resolved line, then
2165        // still show the next chooser. Confirm/Edit only when ready (no suggestion).
2166        if (reviewReady) renderReviewCard(json.draft, json.linePriceGuide || lastLinePriceGuide);
2167        if (json.suggestion) {
2168          if (json.suggestion.question && !json.assistantMessage) appendMessage('assistant', json.suggestion.question);
2169          renderSuggestionCards(json.suggestion);
2170          if (json.chatActions && json.chatActions.length) renderChatActions(json.chatActions);
2171        } else if (json.chatActions && json.chatActions.length) {
2172          renderChatActions(json.chatActions);
2173        } else if (reviewReady && (json.readyToConfirm || json.readyForConfirmButton)
2174            && !json.requiresUserChoice && !json.requiresEditChoice && !createdRequestId) {
2175          renderChatActions([
2176            { action: 'confirm', label: 'Confirm request', kind: 'primary' },
2177            { action: 'edit', label: 'Edit', kind: 'basic' }
2178          ]);
2179        }
2180      }).catch(function (err) {
2181        removeTypingIndicator(typing);
2182        var serverMessage = (err && err.response) ? err.message : '';
2183        appendMessage('assistant', serverMessage || 'I had trouble reaching the server. Please check your connection and try again, or use the quick form below.');
2184        if (manualBtn) manualBtn.hidden = false;
2185        showFallbackActions();
2186        Array.prototype.forEach.call((log && log.querySelectorAll('.fg-chat-action-chips button')) || [], function (b) {
2187          b.disabled = false;
2188        });
2189      }).finally(function () {
2190        // Only the latest request controls the input/busy state.
2191        if (myReq === reqSeq) {
2192          pending = false;
2193          root.classList.remove('is-pending');
2194          setBusy(sendBtn, false);
2195          if (input) {
2196            input.disabled = false;
2197            input.value = '';
2198            input.focus();
2199          }
2200        }
2201      });
2202    }
2203
2204    function send(message) {
2205      return postTurn({ message: message }, message);
2206    }
2207
2208    // Phase 19A-Fix: a clicked product card sends a STRUCTURED selection, never the
2209    // label as chat text — so the backend treats it as exactly one item.
2210    function sendSelection(card) {
2211      var suggestion = {
2212        suggestionId: card.suggestionId || undefined,
2213        source: card.source || 'fallback',
2214        productId: card.productId || undefined,
2215        itemName: card.itemName || card.label || '',
2216        label: card.label || card.itemName || '',
2217        category: card.category || undefined,
2218        listedPrice: card.listedPrice || undefined,
2219        imageUrl: card.imageUrl || undefined,
2220        flexible: card.flexible === true ? true : undefined,
2221        sellerName: card.sellerName || undefined,
2222        merchant: card.merchant || undefined,
2223        packSizeLabel: card.packSizeLabel || undefined,
2224        packQuantity: card.packQuantity || undefined,
2225        packUnit: card.packUnit || undefined,
2226      };
2227      return postTurn({ actionType: 'select_suggestion', suggestion: suggestion }, suggestion.label);
2228    }
2229
2230    if (!restoreChatState()) {
2231      appendMessage('assistant', greetingText, 'greeting');
2232      renderDraft({});
2233    }
2234
2235    if (form) {
2236      form.addEventListener('submit', function (event) {
2237        event.preventDefault();
2238        var message = input ? input.value.trim() : '';
2239        if (!message) return;
2240        // Phase 19A-Fix10.4: a terminal/closed chat must not append to a dead session.
2241        // Typing here starts a fresh craving with the typed message as its first turn.
2242        if (chatClosed) {
2243          if (input) input.value = '';
2244          startNewCraving(message);
2245          return;
2246        }
2247        send(message);
2248      });
2249    }
2250
2251    // Phase 19A: shared confirm/send actions, callable from in-chat chips or the
2252    // hidden fallback buttons. Both hit existing, validated routes only.
2253    function doConfirm(chipWrap) {
2254      if (!sessionId) { pageMessage('Tell me what you’re craving first.', 'error'); return; }
2255      if (createdRequestId) { return; }
2256      requestJson('/broadcast-requests/conversation/' + sessionId + '/confirm', {
2257        method: 'POST',
2258        body: JSON.stringify({}),
2259      }).then(function (json) {
2260        createdRequestId = json.broadcastRequest && json.broadcastRequest._id;
2261        if (confirmBtn) confirmBtn.hidden = true;
2262        if (json.assistantMessage) appendMessage('assistant', json.assistantMessage);
2263        renderChatActions(json.chatActions && json.chatActions.length ? json.chatActions : [
2264          { action: 'send', label: 'Send to nearby sellers', kind: 'primary' },
2265          { action: 'edit', label: 'Edit', kind: 'basic' },
2266        ]);
2267      }).catch(function (err) {
2268        if (chipWrap) Array.prototype.forEach.call(chipWrap.querySelectorAll('button'), function (b) { b.disabled = false; });
2269        pageMessage(err.message, 'error');
2270      });
2271    }
2272
2273    function runBroadcast(chipWrap) {
2274      return requestJson('/broadcast-requests/' + createdRequestId + '/broadcast', {
2275        method: 'POST',
2276      }).then(function (json) {
2277        if (json.availabilityBlocked) {
2278          appendMessage('assistant', json.assistantMessage || json.message || 'No one nearby is accepting community asks right now.');
2279          renderChatActions(json.chatActions || []);
2280          return;
2281        }
2282        var id = json.broadcastRequest && json.broadcastRequest._id ? json.broadcastRequest._id : createdRequestId;
2283        createdRequestId = id;
2284        document.body.setAttribute('data-fg-current-broadcast-request-id', id);
2285        appendMessage('assistant', 'Sent to nearby sellers. I’ll keep this chat open and show seller offers here as they arrive.');
2286        renderChatActions([{ action: 'view', label: 'View request', kind: 'basic' }
2286]);
2287        fetchAndRenderOffer(id);
2288      });
2289    }
2290
2291    function guideToRequestPageHold() {
2292      appendMessage('assistant', 'Almost there — add a card to send your craving. You’re only charged when a seller delivers. I’ll take you there to confirm and send.');
2293      renderChatActions([{ action: 'view_request', label: 'Add card & send', kind: 'primary', value: createdRequestId }]);
2294      window.setTimeout(function () {
2295        window.location.href = '/broadcast-requests/' + createdRequestId + '?returnTo=craving-chat';
2296      }, 900);
2297    }
2298
2299    function startInChatHoldIfNeeded(chipWrap) {
2300      var escrow = escrowConfigFromDom();
2301      if (!escrow.enabled) {
2302        return runBroadcast(chipWrap);
2303      }
2304      return fetchBroadcastRequest(createdRequestId).then(function (request) {
2305        if (hasConfirmedEscrowHold(request)) {
2306          return runBroadcast(chipWrap);
2307        }
2308        if (escrow.stripeKey && window.Stripe) {
2309          mountInChatHold(escrow.stripeKey);
2310          return;
2311        }
2312        guideToRequestPageHold();
2313      });
2314    }
2315
2316    function doBroadcast(chipWrap) {
2317      if (!createdRequestId) { pageMessage('Confirm your craving first.', 'error'); return; }
2318      startInChatHoldIfNeeded(chipWrap).catch(function (err) {
2319        if (chipWrap) Array.prototype.forEach.call(chipWrap.querySelectorAll('button'), function (b) { b.disabled = false; });
2320        // Race fallback: hold confirmed between the pre-check and /broadcast.
2321        var code = err && err.response && err.response.error && err.response.error.code;
2322        if (code === 'BROADCAST_HOLD_REQUIRED') {
2323          var escrow = escrowConfigFromDom();
2324          if (escrow.enabled && escrow.stripeKey && window.Stripe) {
2325            mountInChatHold(escrow.stripeKey);
2326          } else {
2327            guideToRequestPageHold();
2328          }
2329          return;
2330        }
2331        pageMessage(err.message, 'error');
2332      });
2333    }
2334
2335    // Phase 32b: commitment-first hold, placed INLINE in the chat. Reuses the
2336    // /create-hold + /confirm-hold-authorized endpoints and Stripe's Payment
2337    // Element; on success it re-runs doBroadcast (the hold now exists, so the
2338    // backend gate passes). No raw card data is ever handled outside Stripe.
2339    function mountInChatHold(stripeKey) {
2340      // Plain-language payment copy — no "hold" jargon. Buyers understand "you're
2341      // only charged when it's delivered" far better than "authorize a hold".
2342      var priceLabel = (lastDraft && lastDraft.originalOfferedPrice != null)
2343        ? formatMoney(lastDraft.originalOfferedPrice) : 'your offer';
2344      var row = appendMessage('assistant', 'One step left to send — add a card below. You’re not charged now: we only collect ' + priceLabel + ' when a seller delivers your order. If no one takes it, you pay nothing.');
2345      var host = row || lastAssistantRow();
2346      if (!host) { window.location.href = '/broadcast-requests/' + createdRequestId + '?returnTo=craving-chat'; return; }
2347      var wrap = document.createElement('div');
2348      wrap.className = 'fg-chat-hold';
2349      wrap.style.marginTop = '8px';
2350      var expressHost = document.createElement('div');
2351      expressHost.className = 'fg-chat-hold__express fg-broadcast-payment__express';
2352      var elHost = document.createElement('div');
2353      elHost.className = 'fg-chat-hold__element';
2354      elHost.style.margin = '8px 0';
2355      var payBtn = document.createElement('button');
2356      payBtn.type = 'button';
2357      payBtn.className = 'fg-chat-hold__btn fg-chat-hold__btn--primary';
2358      payBtn.textContent = (lastDraft && lastDraft.originalOfferedPrice != null)
2359        ? 'Hold ' + formatMoney(lastDraft.originalOfferedPrice) + ' & send'
2360        : 'Hold & send';
2361      payBtn.disabled = true;
2362      wrap.appendChild(expressHost);
2363      wrap.appendChild(elHost);
2364      wrap.appendChild(payBtn);
2365      host.appendChild(wrap);
2366      if (log) log.scrollTop = log.scrollHeight;
2367
2368      function revealHold() {
2369        try { wrap.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
2370        catch (e) { if (log) log.scrollTop = log.scrollHeight; }
2371      }
2372
2373      var stripe = window.Stripe(stripeKey);
2374      var elements = null;
2375      requestJson('/broadcast-requests/' + createdRequestId + '/create-hold', { method: 'POST' }).then(function (json) {
2376        var opts = { clientSecret: json.clientSecret };
2377        if (json.customerSessionClientSecret) opts.customerSessionClientSecret = json.customerSessionClientSecret;
2378        elements = stripe.elements(opts);
2379        mountExpressCheckoutOn(elements, expressHost, function () { payBtn.click(); });
2380        var paymentEl = elements.create('payment', paymentElementOptions());
2381        // The element mounts async (iframe) and grows after mount(), so an
2382        // immediate scroll lands short. Scroll once it reports ready, with a
2383        // timeout fallback in case the ready event never fires.
2384        paymentEl.on('ready', revealHold);
2385        paymentEl.mount(elHost);
2386        payBtn.disabled = false;
2387        window.setTimeout(revealHold, 600);
2388      }).catch(function (e) {
2389        appendMessage('assistant', (e && e.message) || 'Could not start payment. Try again from the request page.');
2390      });
2391
2392      payBtn.addEventListener('click', function () {
2393        if (!elements) return;
2394        payBtn.disabled = true;
2395        stripe.confirmPayment({ elements: elements, redirect: 'if_required' }).then(function (result) {
2396          if (result.error) throw new Error(result.error.message || 'Hold could not be authorized.');
2397          return requestJson('/broadcast-requests/' + createdRequestId + '/confirm-hold-authorized', { method: 'POST' });
2398        }).then(function () {
2399          if (wrap.parentNode) wrap.parentNode.removeChild(wrap);
2400          appendMessage('assistant', 'Card hold authorized — you have not been charged yet. Sending to nearby sellers…');
2401          doBroadcast();
2402        }).catch(function (e) {
2403          payBtn.disabled = false;
2404          appendMessage('assistant', (e && e.message) || 'Hold could not be authorized. Please try again.');
2405        });
2406      });
2407    }
2408
2409    // Phase 19A-Fix10.4: confirmed cancel of a SENT request. Uses the existing,
2410    // validated /:id/cancel endpoint (which guards accepted-payment requests). Never
2411    // changes payment/seller state itself; on a payment-guarded request it guides the
2412    // buyer to the request page instead of force-cancelling.
2413    // Phase 32-Fix: confirm delivery from the chat. On success the hold is
2414    // captured + the seller's share transferred (escrow) or the destination
2415    // charge captured (legacy), and the craving completes.
2416    function showDeliveryConfirmed(requestId, data) {
2417      appendMessage('assistant', 'Delivery confirmed — payment captured and the seller payout was created. This craving is complete.');
2418      setChatClosed(true);
2419      var nextActions = [];
2420      if (!(data && data.alreadyReviewed)) {
2421        nextActions.push({ action: 'rate_experience', label: 'Rate this experience', kind: 'primary', value: requestId });
2422      }
2423      nextActions.push({ action: 'start_new_craving', label: 'Start new craving', kind: nextActions.length ? 'basic' : 'primary' });
2424      nextActions.push({ action: 'view_request_history', label: 'View request history', kind: 'basic' });
2425      renderChatActions(nextActions);
2426    }
2427
2428    function confirmDeliveryFromChat(requestId, wrap) {
2429      if (!requestId) { pageMessage('No craving to confirm.', 'error'); return; }
2430      requestJson('/broadcast-requests/' + requestId + '/confirm-delivery', { method: 'POST' }).then(function (data) {
2431        showDeliveryConfirmed(requestId, data);
2432      }).catch(function (err) {
2433        // A dropped response still leaves "Failed to fetch" even when the server
2434        // already captured payment. Recheck before showing that as a chat line.
2435        if (err && err.response) throw err;
2436        return requestJson('/broadcast-requests/' + requestId, { method: 'GET' }).then(function (json) {
2437          var status = json && json.broadcastRequest && json.broadcastRequest.status;
2438          if (status === 'completed') {
2439            showDeliveryConfirmed(requestId, { alreadyReviewed: !!(json && json.alreadyReviewed) });
2440            return;
2441          }
2442          throw err;
2443        });
2444      }).catch(function (err) {
2445        if (wrap) Array.prototype.forEach.call(wrap.querySelectorAll('button'), function (b) { b.disabled = false; });
2446        var serverMessage = (err && err.response) ? err.message : '';
2447        appendMessage('assistant', serverMessage || 'Could not confirm delivery here — please confirm from the request page.');
2448        renderChatActions([{ action: 'view_request', label: 'View request', kind: 'primary', value: requestId }]);
2449      });
2450    }
2451
2452    function reportDeliveryProblemFromChat(requestId, wrap) {
2453      if (!requestId) { pageMessage('No craving to report.', 'error'); return; }
2454      var reason = window.prompt('Briefly describe what is missing or wrong with the delivery:');
2455      if (reason === null) return;
2456      reason = String(reason || '').trim();
2457      if (reason.length < 5) {
2458        pageMessage('Please add a brief description so support can review it.', 'error');
2459        return;
2460      }
2461      requestJson('/broadcast-requests/' + requestId + '/report-delivery-problem', {
2462        method: 'POST',
2463        body: JSON.stringify({ reason: reason })
2464      }).then(function () {
2465        appendMessage('assistant', 'Automatic payment release is paused. Support will review the delivery problem.');
2466        renderChatActions([{ action: 'view_request', label: 'View request', kind: 'primary', value: requestId }]);
2467      }).catch(function (err) {
2468        if (wrap) Array.prototype.forEach.call(wrap.querySelectorAll('button'), function (b) { b.disabled = false; });
2469        appendMessage('assistant', (err && err.message) || 'Could not report the delivery problem here. Please contact support.');
2470      });
2471    }
2472
2473    function cancelBroadcastRequest(requestId, el, wrap) {
2474      if (!requestId) { pageMessage('No request to cancel.', 'error'); return; }
2475      requestJson('/broadcast-requests/' + requestId + '/cancel', {
2476        method: 'POST',
2477        body: JSON.stringify({ reason: 'buyer_cancelled' }),
2478      }).then(function () {
2479        appendMessage('assistant', 'Your craving request has been cancelled.');
2480        setChatClosed(true);
2481        renderChatActions([
2482          { action: 'start_new_craving', label: 'Start new craving', kind: 'primary' },
2483          { action: 'view_request_history', label: 'View request history', kind: 'basic' }
2484        ]);
2485      }).catch(function (err) {
2486        if (wrap) Array.prototype.forEach.call(wrap.querySelectorAll('button'), function (b) { b.disabled = false; });
2487        appendMessage('assistant', (err && err.message) || 'This request needs to be cancelled from the request page.');
2488        renderChatActions([
2489          { action: 'view_request', label: 'View request', kind: 'primary', value: requestId },
2490          { action: 'keep_request', label: 'Keep request open', kind: 'basic' }
2491        ]);
2492      });
2493    }
2494
2495    if (confirmBtn) {
2496      confirmBtn.addEventListener('click', function () { doConfirm(); });
2497    }
2498    if (broadcastBtn) {
2499      broadcastBtn.addEventListener('click', function (event) { event.preventDefault(); doBroadcast(); });
2500    }
2501
2502    document.addEventListener('click', function (event) {
2503      var offerBtn = event.target.closest('[data-chat-offer-action]');
2504      if (!offerBtn || !root.contains(offerBtn)) return;
2505      event.preventDefault();
2506      var action = offerBtn.getAttribute('data-chat-offer-action');
2507      var requestId = offerBtn.getAttribute('data-request-id') || createdRequestId;
2508      if (!requestId) return;
2509      // Go is fixed-offer — the only in-chat offer action is the (legacy) payment CTA.
2510      if (action === 'pay') {
2511        window.location.href = '/broadcast-requests/' + requestId;
2512      }
2513    });
2514
2515    if (manualBtn) {
2516      manualBtn.addEventListener('click', function () {
2517        prefillFormFromDraft(lastDraft);
2518        if (typeof setMode === 'function') setMode('form');
2519        pageMessage('Switched to the quick form. Your collected details are kept.', 'info');
2520      });
2521    }
2522
2523    document.addEventListener('fg:broadcast-offer-event', function (event) {
2524      var payload = event && event.detail ? event.detail : {};
2525      if (!createdRequestId && payload.broadcastRequestId) createdRequestId = payload.broadcastRequestId;
2526      fetchAndRenderOffer(payload.broadcastRequestId || createdRequestId, payload.offerId || payload.broadcastOfferId);
2527      // Phase 19D: offer/status transitions can unlock or close the relay.
2528      fetchChatRelay();
2529    });
2530  }
2531
2532  function initActionButtons() {
2533    document.addEventListener('click', function (event) {
2534      // Roadmap D3: "Pass" first reveals the optional reason chips; each chip is a
2535      // real decline action (with or without a data-reason).
2536      var toggle = event.target.closest('[data-decline-toggle]');
2537      if (toggle) {
2538        event.preventDefault();
2539        var scope = toggle.closest('article, section') || document;
2540        var reasons = scope.querySelector('.fg-pass-reasons');
2541        if (reasons) { reasons.hidden = false; toggle.disabled = true; }
2542        return;
2543      }
2544      var btn = event.target.closest('[data-broadcast-action][data-endpoint]');
2545      if (!btn) return;
2546      event.preventDefault();
2547      var action = btn.getAttribute('data-broadcast-action');
2548      var endpoint = btn.getAttribute('data-endpoint');
2549      var body = {};
2550      if (action === 'cancel-payment' || action === 'cancel') {
2551        body.reason = 'buyer_cancelled';
2552      }
2553      if (action === 'report-delivery-problem') {
2554        var reason = window.prompt('Briefly describe what is missing or wrong with the delivery:');
2555        if (reason === null) return;
2556        reason = String(reason || '').trim();
2557        if (reason.length < 5) {
2558          pageMessage('Please add a brief description so support can review it.', 'error');
2559          return;
2560        }
2561        body.reason = reason;
2562      }
2563      // Roadmap D3: pass reason (optional signal chosen via the chips).
2564      if (action === 'decline' && btn.getAttribute('data-reason')) {
2565        body.reason = btn.getAttribute('data-reason');
2566      }
2567      setBusy(btn, true);
2568      requestJson(endpoint, {
2569        method: 'POST',
2570        body: JSON.stringify(body),
2571      }).then(function () {
2572        pageMessage('Updated.', 'success');
2573        window.setTimeout(function () {
2574          window.location.reload();
2575        }, 450);
2576      }).catch(function (err) {
2577        pageMessage(err.message, 'error');
2578      }).finally(function () {
2579        setBusy(btn, false);
2580      });
2581    });
2582  }
2583
2584  // Phase 19E-Fix2: accepted seller can copy the unlocked delivery address.
2585  // The address only ever exists in the DOM when the backend serializer
2586  // allowed it for this viewer — this handler adds no new exposure.
2587  function initCopyAddress() {
2588    document.addEventListener('click', function (event) {
2589      var btn = event.target.closest('[data-copy-address]');
2590      if (!btn) return;
2591      event.preventDefault();
2592      var text = btn.getAttribute('data-copy-address') || '';
2593      if (!text) return;
2594      var done = function () { pageMessage('Address copied.', 'success'); };
2595      if (navigator.clipboard && navigator.clipboard.writeText) {
2596        navigator.clipboard.writeText(text).then(done).catch(function () {});
2597      } else {
2598        var tmp = document.createElement('textarea');
2599        tmp.value = text;
2600        document.body.appendChild(tmp);
2601        tmp.select();
2602        try { document.execCommand('copy'); done(); } catch (e) {}
2603        document.body.removeChild(tmp);
2604      }
2605    });
2606  }
2607
2608  function isSafariBrowser() {
2609    var ua = String(navigator.userAgent || '');
2610    return /Safari/i.test(ua) && !/Chrome|CriOS|Chromium|Edg|FxiOS|Firefox/i.test(ua);
2611  }
2612
2613  function expressWalletsPresent(available) {
2614    if (!available || typeof available !== 'object') return false;
2615    return !!(available.applePay || available.googlePay || available.link
2616      || available.paypal || available.amazonPay);
2617  }
2618
2619  function logExpressCheckoutReady(available) {
2620    if (!window.console || !console.info) return;
2621    if (expressWalletsPresent(available)) {
2622      console.info('[craving-go-payment] express checkout available', available);
2623      return;
2624    }
2625    console.info('[craving-go-payment] express checkout unavailable — no wallet payment methods', {
2626      host: window.location.hostname,
2627      protocol: window.location.protocol,
2628      safari: isSafariBrowser(),
2629      applePaySession: typeof window.ApplePaySession === 'function',
2630      userAgent: String(navigator.userAgent || '').slice(0, 160),
2631      hint: 'Enable Apple Pay / Google Pay / Link in Stripe Dashboard; register Apple Pay domains (www.frenzygo.com and apex if used). Test on a real iPhone Safari. Card fallback remains.',
2632    });
2633  }
2634
2635  // Express Checkout (Apple Pay / Google Pay / Link) above the Payment Element.
2636  // Keep the host visible whenever Stripe returns any wallet — including Link —
2637  // so a saved card in Link does not hide Apple Pay / Google Pay.
2638  function mountExpressCheckoutOn(elements, hostEl, onConfirm) {
2639    if (!hostEl || !elements) return null;
2640    try {
2641      hostEl.hidden = false;
2642      var expressElement = elements.create('expressCheckout');
2643      expressElement.on('ready', function (event) {
2644        var available = event && event.availablePaymentMethods;
2645        logExpressCheckoutReady(available);
2646        hostEl.hidden = !expressWalletsPresent(available);
2647      });
2648      expressElement.on('confirm', function () {
2649        if (typeof onConfirm === 'function') onConfirm();
2650      });
2651      expressElement.mount(hostEl);
2652      return expressElement;
2653    } catch (err) {
2654      hostEl.hidden = true;
2655      if (window.console && console.info) {
2656        console.info('[craving-go-payment] express checkout not mounted: ' + (err && err.message), {
2657          host: window.location.hostname,
2658          safari: isSafariBrowser(),
2659        });
2660      }
2661      return null;
2662    }
2663  }
2664
2665  function paymentElementOptions() {
2666    // Wallets belong on Express Checkout (above). Link/cards stay in Payment Element.
2667    return { wallets: { applePay: 'never', googlePay: 'never' } };
2668  }
2669
2670  function initPayment() {
2671    var page = document.querySelector('[data-broadcast-page="buyer-show"]');
2672    var startBtn = document.getElementById('fgBroadcastStartPaymentBtn');
2673    var confirmBtn = document.getElementById('fgBroadcastConfirmPaymentBtn');
2674    if (!page || !startBtn || !confirmBtn) return;
2675
2676    var requestId = page.getAttribute('data-broadcast-request-id');
2677    var publishableKey = page.getAttribute('data-stripe-publishable-key');
2678    var wrap = document.getElementById('fgBroadcastPaymentElementWrap');
2679    var stripe = null;
2680    var elements = null;
2681
2682    // Phase 32: the SAME Stripe element drives both the legacy post-accept
2683    // authorization and the commitment-first hold placed before broadcasting.
2684    // The endpoints + success copy come from the button so this code is shared.
2685    var createEndpoint = startBtn.getAttribute('data-create-endpoint')
2686      || ('/broadcast-requests/' + requestId + '/create-payment-intent');
2687    var confirmEndpoint = startBtn.getAttribute('data-confirm-endpoint')
2688      || ('/broadcast-requests/' + requestId + '/confirm-payment-authorized');
2689    var successMessage = startBtn.getAttribute('data-success-message')
2690      || 'Card hold authorized. You have not been charged; payment is released after you confirm delivery.';
2691
2692    // Phase 19E-Fix2: one shared authorize flow for the wallet (Express
2693    // Checkout) and the card fallback — manual capture is unchanged; this only
2694    // AUTHORIZES, and capture still happens at delivery confirmation.
2695    function authorizePayment(busyEl) {
2696      if (!stripe || !elements) return pageMessage('Start the authorization first.', 'error');
2697      setBusy(busyEl, true);
2698      stripe.confirmPayment({
2699        elements: elements,
2700        redirect: 'if_required',
2701      }).then(function (result) {
2702        if (result.error) throw new Error(result.error.message || 'Payment could not be authorized.');
2703        return requestJson(confirmEndpoint, {
2704          method: 'POST',
2705        });
2706      }).then(function () {
2707        pageMessage(successMessage, 'success');
2708        window.setTimeout(function () {
2709          window.location.reload();
2710        }, 650);
2711      }).catch(function (err) {
2712        pageMessage(err.message, 'error');
2713      }).finally(function () {
2714        setBusy(busyEl, false);
2715      });
2716    }
2717
2718    startBtn.addEventListener('click', function () {
2719      if (!publishableKey || !window.Stripe) {
2720        return pageMessage('Payment setup is unavailable. Try again later.', 'error');
2721      }
2722      setBusy(startBtn, true);
2723      requestJson(createEndpoint, {
2724        method: 'POST',
2725      }).then(function (json) {
2726        stripe = window.Stripe(publishableKey);
2727        var elementsOpts = { clientSecret: json.clientSecret };
2728        if (json.customerSessionClientSecret) {
2729          elementsOpts.customerSessionClientSecret = json.customerSessionClientSecret;
2730        }
2731        elements = stripe.elements(elementsOpts);
2732        var expressHost = document.getElementById('fgBroadcastExpressCheckout');
2733        mountExpressCheckoutOn(elements, expressHost, function () { authorizePayment(confirmBtn); });
2734        var paymentElement = elements.create('payment', paymentElementOptions());
2735        paymentElement.mount('#fgBroadcastPaymentElement');
2736        if (wrap) wrap.hidden = false;
2737        startBtn.hidden = true;
2738        pageMessage('Authorize your payment below — you’ll only be charged after delivery is confirmed.', 'info');
2739      }).catch(function (err) {
2740        pageMessage(err.message, 'error');
2741      }).finally(function () {
2742        setBusy(startBtn, false);
2743      });
2744    });
2745
2746    confirmBtn.addEventListener('click', function () {
2747      authorizePayment(confirmBtn);
2748    });
2749  }
2750
2751  // Phase 19E: live expiry countdowns. Every element with [data-expires-at]
2752  // ticks once per second; the warning style kicks in under 2 minutes; hitting
2753  // zero on a request page does one refresh so the authoritative expired state
2754  // (lazy backend expiry) renders.
2755  function pad2(n) { return ('0' + n).slice(-2); }
2756
2757  function initExpiryCountdowns() {
2758    var reloadScheduled = false;
2759    function tick() {
2760      var els = document.querySelectorAll('[data-expires-at]');
2761      if (!els.length) return;
2762      var now = Date.now();
2763      Array.prototype.forEach.call(els, function (el) {
2764        var end = Date.parse(el.getAttribute('data-expires-at'));
2765        if (!end) return;
2766        var ms = Math.max(0, end - now);
2767        var t = el.querySelector('.fg-expiry-chip__time');
2768        var totalS = Math.floor(ms / 1000);
2769        if (t) t.textContent = ms > 0 ? Math.floor(totalS / 60) + ':' + pad2(totalS % 60) : 'expired';
2770        el.classList.toggle('fg-expiry-chip--warning', ms > 0 && ms <= 120000);
2771        if (ms === 0 && !el.hasAttribute('data-expired-handled')) {
2772          el.setAttribute('data-expired-handled', '1');
2773          el.classList.add('fg-expiry-chip--expired');
2774          var page = document.querySelector('[data-broadcast-page="buyer-show"], [data-broadcast-page="seller-show"]');
2775          if (page && !reloadScheduled) {
2776            reloadScheduled = true;
2777            window.setTimeout(function () { window.location.reload(); }, 1500);
2778          }
2779        }
2780      });
2781    }
2782    window.setInterval(tick, 1000);
2783    tick();
2784  }
2785
2786  // After a successful extension, refresh every request-scoped chip (never the
2787  // per-counter chips — those windows are the seller's, not the request's).
2788  function applyRequestExpiryUpdate(expiry) {
2789    if (!expiry || !expiry.expiresAt) return;
2790    Array.prototype.forEach.call(document.querySelectorAll('[data-expiry-scope="request"]'), function (el) {
2791      el.setAttribute('data-expires-at', expiry.expiresAt);
2792      el.removeAttribute('data-expired-handled');
2793      el.classList.remove('fg-expiry-chip--expired');
2794    });
2795  }
2796
2797  function initExtendButton() {
2798    document.addEventListener('click', function (event) {
2799      var btn = event.target.closest('#fgBroadcastExtendBtn');
2800      if (!btn) return;
2801      event.preventDefault();
2802      setBusy(btn, true);
2803      requestJson(btn.getAttribute('data-endpoint'), { method: 'POST', body: JSON.stringify({}) }).then(function (json) {
2804        pageMessage('Extended — sellers have a few more minutes to respond.', 'success');
2805        if (json && json.expiry) applyRequestExpiryUpdate(json.expiry);
2806        if (btn.parentNode) btn.parentNode.removeChild(btn);
2807      }).catch(function (err) {
2808        pageMessage(err.message, 'error');
2809      }).finally(function () {
2810        setBusy(btn, false);
2811      });
2812    });
2813  }
2814
2815  // Phase 19D: transaction relay panel on the buyer request page and the
2816  // accepted-seller request page. One request = one thread; the backend is
2817  // authoritative about who can read/post and when. All rendering uses
2818  // textContent (never innerHTML) so message bodies cannot inject markup.
2819  function initTransactionRelay() {
2820    var panel = document.getElementById('fgRelayPanel');
2821    if (!panel) return;
2822    var endpoint = panel.getAttribute('data-relay-endpoint');
2823    var role = panel.getAttribute('data-relay-role') || 'buyer';
2824    var logEl = document.getElementById('fgRelayLog');
2825    var quickWrap = document.getElementById('fgRelayQuickReplies');
2826    var form = document.getElementById('fgRelayForm');
2827    var input = document.getElementById('fgRelayInput');
2828    var sendBtn = document.getElementById('fgRelaySendBtn');
2829    var stateLabel = document.getElementById('fgRelayStateLabel');
2830    var readOnlyNote = document.getElementById('fgRelayReadOnly');
2831    if (!endpoint || !logEl) return;
2832
2833    var fetching = false;
2834
2835    // Date-aware stamp: bare time for today, "Jul 1 · 2:14 PM" for older messages.
2836    function relayTimestamp(value) {
2837      try {
2838        var d = new Date(value);
2839        if (isNaN(d.getTime())) return '';
2840        var t = d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
2841        if (d.toDateString() === new Date().toDateString()) return t;
2842        return d.toLocaleDateString([], { month: 'short', day: 'numeric' }) + ' · ' + t;
2843      } catch (e) { return ''; }
2844    }
2845
2846    function renderRelayMessage(msg) {
2847      if (!msg || !msg._id) return false;
2848      if (logEl.querySelector('[data-relay-id="' + msg._id + '"]')) return false;
2849      var row = document.createElement('div');
2850      var kind = msg.senderRole === 'system' || msg.senderRole === 'craving_go'
2851        ? 'system'
2852        : (msg.mine ? 'mine' : 'theirs');
2853      row.className = 'fg-relay-msg fg-relay-msg--' + kind;
2854      row.setAttribute('data-relay-id', msg._id);
2855      if (kind !== 'system') {
2856        var name = document.createElement('span');
2857        name.className = 'fg-relay-msg__name';
2858        name.textContent = msg.mine ? 'You' : (msg.senderName || (role === 'buyer' ? 'Seller' : 'Buyer'));
2859        row.appendChild(name);
2860      }
2861      var bubble = document.createElement('span');
2862      bubble.className = 'fg-relay-msg__bubble';
2863      bubble.textContent = msg.body || '';
2864      row.appendChild(bubble);
2865      // Timestamp so the order chat shows when each message was sent (system
2866      // narration stays time-free).
2867      var stamp = kind !== 'system' ? relayTimestamp(msg.createdAt) : '';
2868      if (stamp) {
2869        var time = document.createElement('span');
2870        time.className = 'fg-relay-msg__time';
2871        time.textContent = stamp;
2872        row.appendChild(time);
2873      }
2874      logEl.appendChild(row);
2875      return true;
2876    }
2877
2878    function renderQuickReplies(replies) {
2879      if (!quickWrap) return;
2880      quickWrap.innerHTML = '';
2881      if (!replies || !replies.length) { quickWrap.hidden = true; return; }
2882      replies.forEach(function (reply) {
2883        var btn = document.createElement('button');
2884        btn.type = 'button';
2885        btn.className = 'ui mini basic button fg-relay-quick__btn';
2886        btn.textContent = reply.text;
2887        btn.addEventListener('click', function () {
2888          postRelay({ quickReplyKey: reply.key }, btn);
2889        });
2890        quickWrap.appendChild(btn);
2891      });
2892      quickWrap.hidden = false;
2893    }
2894
2895    function applyRelayState(json) {
2896      var relay = json && json.relay ? json.relay : {};
2897      panel.hidden = false;
2898      if (stateLabel) {
2899        stateLabel.textContent = relay.state === 'active'
2900          ? 'Live'
2901          : (relay.state === 'read_only' ? 'Closed' : 'Locked');
2902      }
2903      if (form) form.hidden = !relay.canPost;
2904      renderQuickReplies(relay.canPost ? relay.quickReplies : []);
2905      if (readOnlyNote) {
2906        if (relay.state === 'read_only') {
2907          readOnlyNote.textContent = role === 'buyer'
2908            ? 'This request is closed — the conversation is read-only. Start a new craving any time.'
2909            : 'This request is closed — the conversation is read-only.';
2910          readOnlyNote.hidden = false;
2911        } else {
2912          readOnlyNote.hidden = true;
2913        }
2914      }
2915    }
2916
2917    function markRead() {
2918      requestJson(endpoint + '/read', { method: 'POST', body: JSON.stringify({}) }).catch(function () {});
2919    }
2920
2921    function fetchRelay() {
2922      if (fetching) return;
2923      fetching = true;
2924      requestJson(endpoint, { method: 'GET' }).then(function (json) {
2925        applyRelayState(json);
2926        var appendedTheirs = false;
2927        (json.messages || []).forEach(function (msg) {
2928          var appended = renderRelayMessage(msg);
2929          if (appended && !msg.mine) appendedTheirs = true;
2930        });
2931        logEl.scrollTop = logEl.scrollHeight;
2932        if (appendedTheirs) markRead();
2933      }).catch(function () {
2934        // Leave the panel hidden on errors — never show a broken thread.
2935      }).finally(function () {
2936        fetching = false;
2937      });
2938    }
2939
2940    function postRelay(body, busyEl) {
2941      var el = busyEl || sendBtn;
2942      setBusy(el, true);
2943      requestJson(endpoint, {
2944        method: 'POST',
2945        body: JSON.stringify(body),
2946      }).then(function (json) {
2947        if (json && json.message) renderRelayMessage(json.message);
2948        logEl.scrollTop = logEl.scrollHeight;
2949        if (input) input.value = '';
2950      }).catch(function (err) {
2951        pageMessage(err.message, 'error');
2952      }).finally(function () {
2953        setBusy(el, false);
2954      });
2955    }
2956
2957    if (form) {
2958      form.addEventListener('submit', function (event) {
2959        event.preventDefault();
2960        var text = input ? String(input.value || '').trim() : '';
2961        if (!text) return;
2962        postRelay({ message: text });
2963      });
2964    }
2965
2966    document.addEventListener('fg:relay-message-event', function () {
2967      fetchRelay();
2968    });
2969
2970    fetchRelay();
2971  }
2972
2973  function initRealtime() {
2974    var socket = window.fgSocket;
2975    if (!socket || typeof socket.on !== 'function') return;
2976    var page = document.querySelector('[data-broadcast-page]');
2977    function currentRequestId() {
2978      return (page && page.getAttribute('data-broadcast-request-id'))
2979        || document.body.getAttribute('data-fg-current-broadcast-request-id')
2980        || '';
2981    }
2982
2983    function relevant(payload) {
2984      var id = currentRequestId();
2985      return payload && payload.broadcastRequestId && id && String(payload.broadcastRequestId) === String(id);
2986    }
2987
2988    function dispatchChatOffer(payload) {
2989      document.dispatchEvent(new CustomEvent('fg:broadcast-offer-event', { detail: payload || {} }));
2990    }
2991
2992    function reloadSoon(message) {
2993      toast(message || 'Broadcast updated.', 'info');
2994      if (!currentRequestId() || window.__fgBroadcastReloadScheduled) return;
2995      window.__fgBroadcastReloadScheduled = true;
2996      window.setTimeout(function () {
2997        window.location.reload();
2998      }, 900);
2999    }
3000
3001    socket.on('broadcast:new', function (payload) {
3002      var href = payload && payload.broadcastRequestId ? '/broadcast-requests/seller/' + payload.broadcastRequestId : '/broadcast-requests/seller/available';
3003      toast('Someone nearby needs this.', 'info', href);
3004      if (page && page.getAttribute('data-broadcast-page') === 'seller-available') {
3005        window.setTimeout(function () { window.location.reload(); }, 900);
3006      }
3007    });
3008
3009    socket.on('broadcast:accepted', function (payload) {
3010      if (relevant(payload) && page && page.getAttribute('data-broadcast-page') === 'new') dispatchChatOffer(payload);
3011      else if (relevant(payload)) reloadSoon('A seller accepted your craving.');
3012    });
3013    socket.on('broadcast:status_changed', function (payload) {
3014      if (relevant(payload) && page && page.getAttribute('data-broadcast-page') === 'new') dispatchChatOffer(payload);
3015      else if (relevant(payload)) reloadSoon('Broadcast status updated.');
3016    });
3017    socket.on('broadcast:payment_authorized', function (payload) {
3018      if (relevant(payload)) reloadSoon('Payment authorized.');
3019    });
3020    socket.on('broadcast:marked_delivered', function (payload) {
3021      if (relevant(payload)) reloadSoon('Seller marked the request delivered.');
3022    });
3023    socket.on('broadcast:completed', function (payload) {
3024      if (relevant(payload)) reloadSoon('Broadcast completed.');
3025    });
3026    socket.on('broadcast:cancelled', function (payload) {
3027      if (relevant(payload)) reloadSoon('Broadcast cancelled.');
3028    });
3029    socket.on('broadcast:payment_failed', function (payload) {
3030      if (relevant(payload)) reloadSoon('Payment failed.');
3031    });
3032    // Phase 19D: a relay message NEVER reloads the page — the thread updates in
3033    // place (relay panel and/or Craving Go chat listen for this event).
3034    socket.on('broadcast:relay_message', function (payload) {
3035      if (relevant(payload)) {
3036        document.dispatchEvent(new CustomEvent('fg:relay-message-event', { detail: payload || {} }));
3037      } else if (payload && payload.broadcastRequestId) {
3038        toast('New message about your craving.', 'info', '/broadcast-requests/' + payload.broadcastRequestId);
3039      }
3040    });
3041  }
3042
3043  document.addEventListener('DOMContentLoaded', function () {
3044    initNewPage();
3045    var setMode = initBroadcastModes();
3046    initChatAssistant(setMode);
3047    initActionButtons();
3048    initCopyAddress();
3049    initPayment();
3050    initTransactionRelay();
3051    initExpiryCountdowns();
3052    initExtendButton();
3053    initRealtime();
3054  });
3055})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.