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.