1/** 2 * portal-tenders.js â My Tenders list (Phase D, D5). 3 * 4 * Fetches the bidder's invited/downloaded tenders and renders click-through cards with a closing 5 * countdown, confirm badge, and doc count. Display strings are computed here so the template gets 6 * complete data (JsRender lesson 7c). Clicking a card opens the flat detail (D5b). 7 */ 8import { render, renderTo, loadTemplates } from '../../utils/templates.js'; 9import { afterRender } from '../../utils/dom.js'; 10import { offlineAwareErrorDescription } from '../../utils/async.js'; 11import { portalSession } from '../../portal/portal-session.js'; 12import { portalTendersApi } from '../../api/portal-tenders.js'; 13import { closing, bidBadge, confirmBadge, newsBadge, stepsNudge } from '../../portal/portal-format.js'; 14 15let templatesLoaded = false; 16let showPast = false; 17let _abort = null; 18let _loadSeq = 0; // staleness token â rapid filter toggles race, last CALL must win, not last RESPONSE 19 20/** @param {AbortSignal|null} signal - the route transition's signal; a later route aborts it (CA-556). */ 21export async function initPortalTendersPage(signal = null) { 22 _abort = new AbortController(); 23 const mySignal = _abort.signal; 24 if (!templatesLoaded) { 25 // shared-partials = the canonical tender row (#tpl-tender-row-st â âï¸ ruling 1) 26 await Promise.all([ 27 loadTemplates('portal/portal-tenders'), 28 loadTemplates('components/shared-partials'), 29 ]); 30 templatesLoaded = true; 31 } 32 const bidder = portalSession.getBidder() || {}; 33 const html = await render('#tpl-portal-tenders', { contactName: bidder.contactName || '' }, undefined, undefined, { signal }); 34 if (html === null || mySignal.aborted) return; 35 wireFilter(); 36 37 // Row click â detail. Bound ONCE here on the persistent list container â loadList() only 38 // swaps its innerHTML. (Binding inside loadList stacked one listener per filter toggle, 39 // firing N duplicate navigations per click â audit ð H1.) 40 document.getElementById('portal-tenders-list')?.addEventListener('click', (e) => { 41 const row = e.target.closest('[data-goto-tender]'); 42 if (row) window.page ? window.page(`/portal/tenders/${row.dataset.gotoTender}`) : (location.href = `/portal/tenders/${row.dataset.gotoTender}`); 43 }, _abort ? { signal: _abort.signal } : {}); 44 45 await loadList(); 46} 47 48export function destroyPortalTendersPage() { 49 _abort?.abort(); 50 _abort = null; 51 // Reset filter state â the template's pill markup defaults to "Open", so a leftover 52 // showPast=true would render a list that contradicts the active pill on the next visit. 53 showPast = false; 54} 55 56function wireFilter() { 57 const opts = _abort ? { signal: _abort.signal } : {}; 58 document.getElementById('portal-tenders-filter')?.addEventListener('click', (e) => { 59 const btn = e.target.closest('button[data-past]'); 60 if (!btn) return; 61 showPast = btn.dataset.past === 'true'; 62 document.querySelectorAll('#portal-tenders-filter button').forEach(b => 63 b.classList.toggle('pill-btn-st-active', b === btn)); 64 loadList(); 65 }, opts); 66} 67 68async function loadList() { 69 const listEl = document.getElementById('portal-tenders-list'); 70 if (!listEl) return; 71 // Capture this call's identity (signal + sequence) â post-await checks must not read live 72 // module state, and a slower older response must never render over a newer one (audit C2/M2). 73 const mySignal = _abort?.signal; 74 const seq = ++_loadSeq; 75 // restoreFocus: the retry that reached here is destroyed by THIS render, not the error one 76 // below, so the chain starts owning focus at the skeleton (CA-216, MCF-0685). 77 await renderTo(listEl, '#tpl-portal-tenders-skeleton', {}, undefined, { restoreFocus: true }); 78 79 let data; 80 try { 81 data = await portalTendersApi.getMyTenders(showPast, { signal: mySignal }); 82 } catch (err) { 83 if (!mySignal || mySignal.aborted || seq !== _loadSeq) return; 84 // Error state + retry instead of a silent blank (functionality-audit ð variant). 85 window.showToast?.('Could not load your tenders', 'error', { description: offlineAwareErrorDescription(err) }); 86 await renderTo(listEl, '#tpl-portal-tenders-error', {}, undefined, { restoreFocus: true }); 87 // renderTo awaits, so the guard above no longer covers this bind (CA-216, MCF-0685). 88 if (!mySignal || mySignal.aborted || seq !== _loadSeq) return; 89 document.getElementById('portal-tenders-retry')?.addEventListener('click', () => loadList(), { signal: mySignal }); 90 return; 91 } 92 if (!mySignal || mySignal.aborted || seq !== _loadSeq) return; 93 94 const tenders = (data.tenders || []).map(decorate); 95 listEl.innerHTML = ''; 96 if (tenders.length === 0) { 97 const html = $.templates('#tpl-portal-tenders-empty').render({ _showPast: showPast }); 98 listEl.insertAdjacentHTML('beforeend', html); 99 } else { 100 const html = $.templates('#tpl-portal-tenders-cardlist').render({ tenders }); 101 listEl.insertAdjacentHTML('beforeend', html); 102 } 103 afterRender(listEl); 104} 105 106// ââ Display-field computation (kept out of the template) ââ 107// The badge/closing helpers moved to portal/portal-format.js (E8) â the dashboard 108// decorates the same list rows, so the two surfaces share one implementation. 109 110/** One list row on the shared #tpl-tender-row-st contract (âï¸ ruling 1). Nothing dropped: 111 * Updated/Addendum ride the eyebrow chips, closing/steps/docs the meta line, bid+confirm 112 * the right badge stack. */ 113function decorate(t) { 114 const c = closing(t.bidEndDate, t.isOpen); 115 const bid = bidBadge(t.myBidStatus); 116 const conf = confirmBadge(t.confirmAnswer); 117 const news = newsBadge(t); 118 const steps = stepsNudge(t); 119 120 const chips = []; 121 if (t.addendumNum) chips.push({ label: `Addendum ${t.addendumNum}`, cls: 'badge-st-warning' }); 122 if (news.newsLabel) chips.push({ label: news.newsLabel, cls: news.newsClass, icon: news.newsIcon }); 123 124 const metaItems = [{ icon: 'clock', text: c.closingLabel, cls: c.closingClass }]; 125 if (steps.stepsLabel) metaItems.push({ icon: steps.stepsIcon, text: steps.stepsLabel, cls: steps.stepsClass }); 126 if (t.docCount) metaItems.push({ icon: 'file-text', text: `${t.docCount} document${t.docCount === 1 ? '' : 's'}`, cls: 'text-theme-secondary' }); 127 128 const rightBadges = []; 129 if (bid.bidLabel) rightBadges.push({ label: bid.bidLabel, cls: bid.bidClass, icon: bid.bidIcon }); 130 rightBadges.push({ label: conf.confirmLabel, cls: conf.confirmClass, icon: conf.confirmIcon }); 131 132 return { 133 tenderId: t.tenderId, 134 tnd: t.tenderNumber || '', 135 chips, 136 title: t.projectName,
137 locationText: [t.city, t.provState].filter(Boolean).join(', '), 138 metaItems, 139 rightBadges, 140 }; 141}
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.