PageSourceSearch

https://smrtbld.ai/js/pages/portal/portal-tenders.js

js smrtbld.ai collected 2026-10-05 15:07:55 UTC 6,907 bytes, 141 lines download raw bytes

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.