joelby

Qantas SC Display

Sep 7th, 2026 (edited)
240
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
JavaScript 81.44 KB | Travel | 0 0
  1.  
  2.  
  3.  
  4. // Hi AFF friends!
  5. //
  6. // I've moved this script to https://greasyfork.org/en/scripts/595609-qantas-sc-display .
  7. // You'll find the newest version there, and they provide a one-click install.
  8. //
  9. // joelby
  10.  
  11.  
  12.  
  13.  
  14. // ==UserScript==
  15. // @name         Qantas SC display
  16. // @namespace    https://book.qantas.com/
  17. // @version      2026-09-13
  18. // @description  Display SC/point earn, booking class, plane type, every domestic fare at once, and AUD-converted fares on Qantas booking pages
  19. // @match        https://book.qantas.com/*
  20. // @match        https://www.qantas.com/*
  21. // @icon         https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
  22. // @grant        unsafeWindow
  23. // @grant        GM_xmlhttpRequest
  24. // @grant        GM_getValue
  25. // @grant        GM_setValue
  26. // @connect      open.er-api.com
  27. // @connect      api.frankfurter.dev
  28. // @run-at       document-start
  29.  
  30. // ==/UserScript==
  31. //
  32. // document-start gets the XMLHttpRequest patch in before the availability
  33. // request goes out, which the earn capture below depends on. The patch has to
  34. // land on the *page's* XMLHttpRequest, the one Angular calls — and this script
  35. // no longer runs in page scope, because the currency conversion needs
  36. // GM_xmlhttpRequest (book.qantas.com's CSP blocks a page-context fetch to any
  37. // rate API — see the currency section). Hence unsafeWindow below.
  38. //
  39. // The www.qantas.com match is for the search widget's date picker only, which
  40. // is a separate site and a separate app — see the "Select travel dates"
  41. // section. Nothing else in here runs there.
  42.  
  43. (function () {
  44.     'use strict';
  45.  
  46.     // Two sites, no shared markup: book.qantas.com is the Amadeus booking
  47.     // engine, www.qantas.com is the search widget whose date picker is the
  48.     // only thing worth touching there.
  49.     const onBookingSite = location.hostname === 'book.qantas.com';
  50.  
  51.     // ---- Real earn totals, captured from the availability payload ---------
  52.     //
  53.     // The sr-only summary in the fare grid only carries the Qantas portion of
  54.     // the earn. On a mixed Qantas/partner itinerary the partner sector is
  55.     // missing from it, so the grid can read well below the total the expanded
  56.     // fare panel shows (e.g. 5,375 / 60 SC in the grid vs 6,475 / 80 SC in the
  57.     // panel — the LAX-ANC sector on Alaska).
  58.     //
  59.     // The full figures are in the availability response, which arrives as JSON
  60.     // from POSTs to /qf-booking/dyn/air/booking/<action> (preAvailability...,
  61.     // flexPricerAvailability..., upsellUpdateAction — the action varies with
  62.     // how the results were reached, so match on the payload shape instead):
  63.     //
  64.     //   modelInput.availability.bounds[b].flights[itemId]
  65.     //     .listRecommendation[fareCode].earnPointsAmountList    // [qantas, partner]
  66.     //     .listRecommendation[fareCode].statusCreditAmountList  // [qantas, partner]
  67.     //
  68.     // Element 0 is exactly what the sr-only text reports; the total is the sum.
  69.     // Verified against every fare cell on a mixed QF/Alaska search: 35 of 35
  70.     // matched element 0, with the partner element accounting for the gap.
  71.     //
  72.     // The same recommendation also carries the fare price, which is what the
  73.     // currency section below converts:
  74.     //
  75.     //     .listRecommendation[fareCode].priceForOne
  76.     //       { totalAmount: 18092.8, currency: { code: "ZAR", nbDecimals: 2 } }
  77.     //
  78.     // The grid ties back to the payload through the fare radio's id, which is
  79.     // a_<bound>_<itemId>_<fareCode>.
  80.  
  81.     const earnTotals = new Map();
  82.     let earnVersion = 0;
  83.  
  84.     // ---- Fare families ----------------------------------------------------
  85.     //
  86.     // The domestic grid (see "Every fare, without the clicking" below) needs to
  87.     // know what fares exist before it knows which of them a given flight sells,
  88.     // so the families get indexed alongside the earn figures:
  89.     //
  90.     //   availability.listFareFamily.sortedFareFamilies[]
  91.     //     { code: "AUAURED1JQ", name: "Red e-Deal", hierarchy: 7200,
  92.     //       rewardPlusName: null, isMarginal: false,
  93.     //       belongsToFirstDesktopColumn: true }
  94.     //
  95.     // belongsToFirstDesktopColumn is the cabin split the grid draws — true for
  96.     // the "Economy Cabin" column, false for "Business Cabin". "Marginal" is the
  97.     // payload's word for a Classic Reward fare, the one priced in points.
  98.  
  99.     const boundFareFamilies = new Map();   // boundIndex -> [meta], display order
  100.  
  101.     function indexFareFamilies(boundIndex, bound, families) {
  102.         if (!Array.isArray(families)) return;
  103.  
  104.         const flights = bound.flights || {};
  105.  
  106.         // A family can be sold on the route but not on every departure, and a
  107.         // column of fares no flight offers is just noise — so narrow to what
  108.         // this bound actually quotes somewhere.
  109.         const sold = new Set();
  110.         Object.keys(flights).forEach(itemId => {
  111.             Object.keys(flights[itemId]?.listRecommendation || {}).forEach(code => sold.add(code));
  112.         });
  113.  
  114.         const metas = families.filter(f => f && sold.has(f.code)).map(f => ({
  115.             code: f.code,
  116.             name: f.name,
  117.             rewardPlusName: f.rewardPlusName,
  118.             hierarchy: f.hierarchy || 0,
  119.             firstColumn: f.belongsToFirstDesktopColumn !== false,
  120.             isReward: !!f.isMarginal
  121.         }));
  122.  
  123.         // Display order inside a cabin, matching the expanded panel: revenue
  124.         // fares by descending hierarchy, then the reward fare. sortedFareFamilies
  125.         // is nearly that but leads with the reward fare — it comes down
  126.         // ACEECO (8800), Red e-Deal (7200), Flex (7100) where the panel shows
  127.         // Red e-Deal, Flex, Classic Plus.
  128.         metas.sort((a, b) => (a.isReward - b.isReward) || (b.hierarchy - a.hierarchy));
  129.  
  130.         boundFareFamilies.set(String(boundIndex), metas);
  131.     }
  132.  
  133.     // ---- Date strip prices ------------------------------------------------
  134.     //
  135.     // The "lowest fares around your travel date" strip above the grid is
  136.     // priced in the same local currency as the fares, and comes down keyed by
  137.     // bound in one of two places depending on which request produced it:
  138.     //
  139.     //   modelInput.calendars[bound].tabs[]          // flexCalendarOWCAction,
  140.     //                                               // i.e. the Prev/Next
  141.     //                                               // 14 days buttons
  142.     //   modelInput.availability.calendars[bound].tabs[]
  143.     //
  144.     // Both hold { date, price: { totalAmount, currency: { code } } } per tab,
  145.     // and the tab index lines up with the button's id, e2e-tab-date-<bound>-<n>.
  146.     //
  147.     // Object.keys covers both shapes: the first is an object keyed "0", "1",
  148.     // the second a plain array.
  149.  
  150.     const calendarPrices = new Map();
  151.  
  152.     // The currency for the search as a whole. The strip is painted on first
  153.     // load, so it needs a currency even on the pass where only the grid's
  154.     // payload has been seen.
  155.     let searchCurrency = null;
  156.  
  157.     function indexCalendarPayload(model) {
  158.         let added = 0;
  159.  
  160.         [model.calendars, model.availability?.calendars].forEach(calendars => {
  161.             if (!calendars || typeof calendars !== 'object') return;
  162.  
  163.             Object.keys(calendars).forEach(boundIndex => {
  164.                 const tabs = calendars[boundIndex]?.tabs;
  165.                 if (!Array.isArray(tabs)) return;
  166.  
  167.                 tabs.forEach((tab, tabIndex) => {
  168.                     const price = tab?.price;
  169.                     if (!(price?.totalAmount > 0)) return;
  170.  
  171.                     searchCurrency = price.currency?.code || searchCurrency;
  172.                     calendarPrices.set(`${boundIndex}_${tabIndex}`, {
  173.                         price: price.totalAmount,
  174.                         currency: searchCurrency
  175.                     });
  176.                     added++;
  177.                 });
  178.             });
  179.         });
  180.  
  181.         // Paging the strip restates every date, same as a new search does for
  182.         // the grid.
  183.         if (added) rerender();
  184.     }
  185.  
  186.     function indexPayload(text) {
  187.         let model;
  188.         try {
  189.             model = JSON.parse(text)?.modelInput;
  190.         } catch (e) {
  191.             return; // not JSON, or not ours
  192.         }
  193.         if (!model) return;
  194.  
  195.         // The date strip comes down in its own responses as well as beside the
  196.         // grid, so both get offered to both indexers.
  197.         indexCalendarPayload(model);
  198.         indexEarnPayload(model);
  199.     }
  200.  
  201.     function indexEarnPayload(model) {
  202.         // Every price in the search is quoted in this, and the date strip
  203.         // needs it when its own payload never came past the hook. Read it
  204.         // before the bounds check, which not every availability payload passes.
  205.         searchCurrency = model.availability?.currency?.code || searchCurrency;
  206.  
  207.         const bounds = model.availability?.bounds;
  208.         if (!Array.isArray(bounds)) return;
  209.  
  210.         // Every fare family the search can sell, with the names and the column
  211.         // assignment the domestic grid below needs. Stated once for the search,
  212.         // not per bound.
  213.         const families = model.availability?.listFareFamily?.sortedFareFamilies;
  214.  
  215.         let added = 0;
  216.         bounds.forEach((bound, boundIndex) => {
  217.             const flights = bound?.flights;
  218.             if (!flights) return;
  219.  
  220.             indexFareFamilies(boundIndex, bound, families);
  221.             indexSegments(boundIndex, bound);
  222.  
  223.             // opAirlines is the partner operating carriers only — Qantas
  224.             // subsidiaries (Alliance, QantasLink) are listed separately under
  225.             // subsidiariesOpAirlines and earn in the Qantas bucket, which is
  226.             // why they don't belong here.
  227.             const carriersByItem = {};
  228.             (bound.listItineraries?.itineraries || []).forEach(itinerary => {
  229.                 carriersByItem[itinerary.itemId] = (itinerary.opAirlines || [])
  230.                     .map(a => ({ code: a.code, name: a.capitalizedName }))
  231.                     .filter(a => a.code);
  232.             });
  233.  
  234.             Object.keys(flights).forEach(itemId => {
  235.                 const recommendations = flights[itemId]?.listRecommendation || {};
  236.  
  237.                 Object.keys(recommendations).forEach(fareCode => {
  238.                     const rec = recommendations[fareCode];
  239.                     const points = Array.isArray(rec?.earnPointsAmountList) ? rec.earnPointsAmountList : [];
  240.                     const credits = Array.isArray(rec?.statusCreditAmountList) ? rec.statusCreditAmountList : [];
  241.  
  242.                     // A Classic Reward fare earns nothing but still has a cash
  243.                     // component worth converting, so price alone is enough to
  244.                     // make an entry. (Sold-out cells get filtered at render.)
  245.                     const price = rec?.priceForOne;
  246.                     if (!points.length && !credits.length && !(price?.totalAmount > 0)) return;
  247.  
  248.                     const sum = list => list.reduce((total, n) => total + (Number(n) || 0), 0);
  249.                     earnTotals.set(`${boundIndex}_${itemId}_${fareCode}`, {
  250.                         // Per person, and matching what the cell prints: on a
  251.                         // 2-adult search the cell still read R18,093 while
  252.                         // priceForAll was 36,185.60.
  253.                         price: price?.totalAmount,
  254.                         // Stated as an ISO code per fare, so no guessing from
  255.                         // the symbol. availability.currency says the same thing
  256.                         // for the search as a whole; keep it as the fallback.
  257.                         currency: price?.currency?.code || model.availability?.currency?.code,
  258.                         qantasPoints: points[0] || 0,
  259.                         qantasCredits: credits[0] || 0,
  260.                         partnerPoints: sum(points.slice(1)),
  261.                         partnerCredits: sum(credits.slice(1)),
  262.                         points: sum(points),
  263.                         credits: sum(credits),
  264.                         partnerCarriers: carriersByItem[itemId] || [],
  265.                         // The cash half of a Classic Reward fare — what the
  266.                         // domestic grid prints as "+ $103" beside the points.
  267.                         tax: price?.tax,
  268.                         // ...and the points half. The payload never labels it
  269.                         // as points: convertedBaseFare is the fare before tax
  270.                         // expressed in points, which for a reward fare is the
  271.                         // number of points it costs.
  272.                         rewardPoints: price?.convertedBaseFare,
  273.                         // Classic Plus rather than plain Classic, which changes
  274.                         // the fare's name and nothing else.
  275.                         isRewardPlus: !!rec.isRewardPlus,
  276.                         // Only set when Qantas wants to say "2 left at this
  277.                         // price"; null the rest of the time.
  278.                         seatsLeft: rec.nbLastSeatsAvailable,
  279.                         // rbds is the booking class per segment, in segment
  280.                         // order, and differs per fare family — e.g. a Sale fare
  281.                         // priced Q/Q/G, where the G is the Alaska sector that
  282.                         // earns nothing.
  283.                         bookingClasses: Array.isArray(rec.rbds) ? rec.rbds.slice() : []
  284.                     });
  285.                     added++;
  286.                 });
  287.             });
  288.         });
  289.  
  290.         if (added) {
  291.             // A new search (date change, filter) restates every fare, so let
  292.             // the already-injected cells re-render against the new figures.
  293.             earnVersion++;
  294.             console.log(`[Qantas SC] captured earn totals for ${added} fares`);
  295.  
  296.             // Angular usually redraws the grid straight after, which the
  297.             // observer picks up, but don't rely on it.
  298.             rerender();
  299.         }
  300.     }
  301.  
  302.     function installEarnCapture() {
  303.         // Any @grant other than `none` moves this script into the userscript
  304.         // manager's sandbox, where `window` is not the page's window. Angular
  305.         // calls the page's XMLHttpRequest, so that's the prototype to patch.
  306.         const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  307.         const XHR = pageWindow.XMLHttpRequest;
  308.  
  309.         const open = XHR.prototype.open;
  310.         const send = XHR.prototype.send;
  311.  
  312.         XHR.prototype.open = function (method, url, ...rest) {
  313.             this._qantasScUrl = url;
  314.             return open.call(this, method, url, ...rest);
  315.         };
  316.  
  317.         XHR.prototype.send = function (...args) {
  318.             if (String(this._qantasScUrl || '').includes('/dyn/air/booking/')) {
  319.                 this.addEventListener('load', () => {
  320.                     try {
  321.                         indexPayload(this.responseText);
  322.                     } catch (e) {
  323.                         console.warn('[Qantas SC] failed to index payload', e);
  324.                     }
  325.                 });
  326.             }
  327.             return send.apply(this, args);
  328.         };
  329.     }
  330.  
  331.     function lookupEarn(fareCell) {
  332.         const id = fareCell.querySelector('input[type=radio]')?.id || '';
  333.         const match = id.match(/^a_(\d+)_(\d+)_(.+)$/);
  334.         return match ? earnTotals.get(`${match[1]}_${match[2]}_${match[3]}`) : undefined;
  335.     }
  336.  
  337.     // ---- Local-currency fares, converted to AUD ---------------------------
  338.     //
  339.     // A search from an overseas point of sale prices in that market's currency
  340.     // — JNB-ADL off the ZA site quotes R19,613 — and the symbol alone doesn't
  341.     // pin it down ("R" is the rand here, but it's also the real and the
  342.     // ringgit elsewhere). The payload doesn't leave it to the symbol: the
  343.     // amount comes with an ISO code beside it, per fare, as
  344.     // priceForOne.currency.code ("ZAR"), with availability.currency saying the
  345.     // same for the search as a whole. So the conversion never has to guess.
  346.     //
  347.     // Rates come from a public FX API. book.qantas.com serves a strict CSP
  348.     // whose connect-src allowlist has no room for one, so a page-context
  349.     // fetch() to any rate host fails outright ("Failed to fetch") — which is
  350.     // what forces GM_xmlhttpRequest here, and the sandbox/unsafeWindow dance
  351.     // up in installEarnCapture with it.
  352.  
  353.     const HOME_CURRENCY = 'AUD';
  354.  
  355.     // Rates move slowly enough that a fare grid doesn't need them fresher than
  356.     // this, and both sources publish daily anyway.
  357.     const RATE_TTL_MS = 12 * 60 * 60 * 1000;
  358.     const RATE_STORE = 'qantas-sc-fx-rates';
  359.  
  360.     // Tried in order. exchangerate-api's open endpoint covers ~160 currencies,
  361.     // which matters because Qantas sells from points of sale (AED, ARS, CLP)
  362.     // that the ECB — and so Frankfurter, the fallback — doesn't publish.
  363.     const RATE_SOURCES = [
  364.         {
  365.             name: 'exchangerate-api',
  366.             url: code => `https://open.er-api.com/v6/latest/${code}`,
  367.             read: data => ({ rate: data?.rates?.[HOME_CURRENCY], date: data?.time_last_update_utc })
  368.         },
  369.         {
  370.             name: 'frankfurter/ECB',
  371.             url: code => `https://api.frankfurter.dev/v1/latest?base=${code}&symbols=${HOME_CURRENCY}`,
  372.             read: data => ({ rate: data?.rates?.[HOME_CURRENCY], date: data?.date })
  373.         }
  374.     ];
  375.  
  376.     const rates = new Map();       // code -> { rate, date, source, fetchedAt }
  377.     const rateAsked = new Set();   // one round of attempts per currency per load
  378.     let rateVersion = 0;
  379.  
  380.     let rateStore = null;
  381.  
  382.     function readRateCache() {
  383.         if (rateStore) return rateStore;
  384.         try {
  385.             rateStore = JSON.parse(GM_getValue(RATE_STORE, '{}')) || {};
  386.         } catch (e) {
  387.             rateStore = {};
  388.         }
  389.         return rateStore;
  390.     }
  391.  
  392.     function rateFor(code) {
  393.         if (!code || code === HOME_CURRENCY) return null;
  394.         if (rates.has(code)) return rates.get(code);
  395.  
  396.         const cached = readRateCache()[code];
  397.         if (cached && Date.now() - cached.fetchedAt < RATE_TTL_MS) {
  398.             rates.set(code, cached);
  399.             return cached;
  400.         }
  401.  
  402.         requestRate(code);
  403.  
  404.         // A stale rate beats an empty line while the refresh is in flight, and
  405.         // beats nothing at all if the fetch fails. Deliberately not promoted
  406.         // into `rates`, so a successful refresh still replaces it.
  407.         return cached || null;
  408.     }
  409.  
  410.     function requestRate(code) {
  411.         if (rateAsked.has(code)) return;
  412.         rateAsked.add(code);
  413.  
  414.         const attempt = index => {
  415.             const source = RATE_SOURCES[index];
  416.             if (!source) {
  417.                 console.warn(`[Qantas SC] no ${code}->${HOME_CURRENCY} rate available`);
  418.                 return;
  419.             }
  420.  
  421.             GM_xmlhttpRequest({
  422.                 method: 'GET',
  423.                 url: source.url(code),
  424.                 responseType: 'json',
  425.                 timeout: 10000,
  426.                 onload: response => {
  427.                     // responseType isn't honoured by every manager/version
  428.                     let payload = response.response;
  429.                     if (payload == null || typeof payload === 'string') {
  430.                         try {
  431.                             payload = JSON.parse(response.responseText);
  432.                         } catch (e) {
  433.                             payload = null;
  434.                         }
  435.                     }
  436.  
  437.                     const { rate, date } = source.read(payload) || {};
  438.                     if (!(rate > 0)) return attempt(index + 1);
  439.  
  440.                     const entry = {
  441.                         rate,
  442.                         date: String(date || '').slice(0, 30),
  443.                         source: source.name,
  444.                         fetchedAt: Date.now()
  445.                     };
  446.                     rates.set(code, entry);
  447.  
  448.                     const cache = readRateCache();
  449.                     cache[code] = entry;
  450.                     try {
  451.                         GM_setValue(RATE_STORE, JSON.stringify(cache));
  452.                     } catch (e) {
  453.                         // a rate we can't cache is still a rate we can use
  454.                     }
  455.  
  456.                     console.log(`[Qantas SC] 1 ${code} = ${rate} ${HOME_CURRENCY} (${source.name})`);
  457.  
  458.                     // Anything rendered before the rate landed needs a
  459.                     // second pass.
  460.                     rateVersion++;
  461.                     rerender();
  462.                 },
  463.                 onerror: () => attempt(index + 1),
  464.                 ontimeout: () => attempt(index + 1)
  465.             });
  466.         };
  467.  
  468.         attempt(0);
  469.     }
  470.  
  471.     const audAmount = new Intl.NumberFormat('en-AU', { maximumFractionDigits: 0 });
  472.     const rateDigits = new Intl.NumberFormat('en-AU', { maximumSignificantDigits: 5 });
  473.  
  474.     function buildConvertedPrice(price, code) {
  475.         const info = rateFor(code);
  476.         if (!info) return null;
  477.  
  478.         const stale = Date.now() - info.fetchedAt >= RATE_TTL_MS;
  479.  
  480.         const line = document.createElement('div');
  481.         line.className = 'qantas-sc-aud';
  482.         line.style.fontSize = '0.9em';
  483.         line.style.opacity = '0.75';
  484.         line.textContent = `≈ A$${audAmount.format(price * info.rate)}`;
  485.         line.title = `${audAmount.format(price)} ${code} at 1 ${code} = ` +
  486.             `${rateDigits.format(info.rate)} ${HOME_CURRENCY}` +
  487.             `\n${info.source}${info.date ? `, ${info.date}` : ''}${stale ? ' (stale)' : ''}`;
  488.         return line;
  489.     }
  490.  
  491.     // The money element in a date tab is .amount.cash on a cash search, and
  492.     // .reward-tax ("+ R4,991") on a Classic Reward one — where the .amount
  493.     // beside it carries the class "points" and is a points figure, not money.
  494.     // Anything without one of these is a "No flights" date.
  495.     const CALENDAR_PRICE = 'span.amount.cash, span.reward-tax';
  496.  
  497.     // "R18,093", "+ R4,991". The symbol varies by market and the grouping
  498.     // doesn't on the English-language sites, which is all this has to survive:
  499.     // it's only ever a fallback for a strip whose payload never came past the
  500.     // XHR hook, and the figure is the payload's own rounded to whole units.
  501.     function parseDisplayedAmount(text) {
  502.         const value = Number(String(text || '').replace(/[^\d.,]/g, '').replace(/,/g, ''));
  503.         return value > 0 ? value : 0;
  504.     }
  505.  
  506.     function lookupCalendarPrice(tab) {
  507.         const shown = parseDisplayedAmount(tab.querySelector(CALENDAR_PRICE)?.textContent);
  508.  
  509.         // The id is positional, so an indexed price is only good while the
  510.         // strip still shows the page it came from — paging repaints the
  511.         // buttons under the same ids, and not every repaint comes with a
  512.         // payload we saw. The displayed figure is the payload's own rounded to
  513.         // whole units, which makes it the check as well as the fallback.
  514.         const match = (tab.id || '').match(/^e2e-tab-date-(\d+)-(\d+)$/);
  515.         const indexed = match && calendarPrices.get(`${match[1]}_${match[2]}`);
  516.         if (indexed && (!shown || Math.round(indexed.price) === shown)) return indexed;
  517.  
  518.         return shown && searchCurrency ? { price: shown, currency: searchCurrency } : null;
  519.     }
  520.  
  521.     function insertPricesIntoCalendar() {
  522.         document.querySelectorAll('button.cal-tab-body').forEach(tab => {
  523.             const existing = tab.querySelector('.qantas-sc-aud');
  524.             const priceEl = tab.querySelector(CALENDAR_PRICE);
  525.  
  526.             const entry = priceEl && lookupCalendarPrice(tab);
  527.             const line = entry && entry.currency !== HOME_CURRENCY
  528.                 ? buildConvertedPrice(entry.price, entry.currency)
  529.                 : null;
  530.  
  531.             if (!line) {
  532.                 // Paged to a date with no flights, or a rate we can't get
  533.                 if (existing) existing.remove();
  534.                 return;
  535.             }
  536.  
  537.             // Paging the strip repaints the price in place and leaves our line
  538.             // alone, holding the previous date's figure — and it doesn't
  539.             // always come with a payload to bump a version counter on.
  540.             // Comparing the text is cheaper than working out whether a
  541.             // repaint happened.
  542.             if (existing) {
  543.                 if (existing.textContent === line.textContent) return;
  544.                 existing.remove();
  545.             }
  546.  
  547.             // The strip zeroes the font size on the price wrapper (the usual
  548.             // trick for killing whitespace between inline-blocks) and sets it
  549.             // again per element, so an em-relative size here computes to 0 and
  550.             // the line renders invisibly. Absolute units, and a line-height to
  551.             // match — the date above it is 12px, the price 18px.
  552.             line.style.fontSize = '11px';
  553.             line.style.lineHeight = '1.4';
  554.             line.style.textAlign = 'center';
  555.             line.style.whiteSpace = 'nowrap';
  556.             (priceEl.closest('.price-group') || tab).appendChild(line);
  557.         });
  558.     }
  559.  
  560.     function rerender() {
  561.         if (document.body) requestAnimationFrame(render);
  562.     }
  563.  
  564.     // Revert to the 'better' booking mode
  565.     function removeddT26() {
  566.         const body = document.body;
  567.  
  568.         body.classList.remove('ddT26Cash', 'ddT26');
  569.         console.log("Removing..");
  570.     }
  571.  
  572.     // Each entry in the sr-only summary is either a single value or split into
  573.     // "<x> for Qantas operated flights and <y> for Partners operated flights".
  574.     // Points and Status Credits get split independently of the other entries,
  575.     // so the split form has to be tested for per label rather than by looking
  576.     // for "Partners operated flights" anywhere in the string: an itinerary can
  577.     // have one earn rate but partner-specific seating or upgrade rules.
  578.     //
  579.     // Values are a number, "None" (sold out, Classic Reward) or an en dash
  580.     // (partner legs that earn nothing).
  581.     const EARN_VALUE = '[\\d,]+|None|[\\u2013\\u2014-]';
  582.  
  583.     function parseEarned(text, label) {
  584.         const split = text.match(new RegExp(
  585.             `${label} (${EARN_VALUE}) for Qantas operated flights and (${EARN_VALUE}) for Partners operated flights`));
  586.         if (split) return { qantas: split[1], partner: split[2] };
  587.  
  588.         // The lookahead needs the comma that ends the entry, and needs it not
  589.         // to be a thousands separator: without it "5,300 for Qantas operated
  590.         // flights and ..." backtracks down to a match of just "5".
  591.         const single = text.match(new RegExp(`${label} (${EARN_VALUE})(?=,(?!\\d))`));
  592.         return single ? { qantas: single[1], partner: single[1] } : null;
  593.     }
  594.  
  595.     const earnsAnything = value => /\d/.test(value || '');
  596.     const earnLabel = value => (earnsAnything(value) ? value : '–');
  597.  
  598.     const group = n => n.toLocaleString('en-AU');
  599.  
  600.     // Qantas marks an unbookable fare with the "inactive" class on the cell
  601.     // (the visible text is a localised "No seats", so the class is the safer
  602.     // signal).
  603.     const isSoldOut = fareCell => fareCell.classList.contains('inactive');
  604.  
  605.     // Booking classes come per segment, so show them in segment order. Worth
  606.     // surfacing because the class is what drives the earn — two otherwise
  607.     // identical itineraries can differ only in, say, a G vs Q partner sector.
  608.     function buildBookingClasses(classes) {
  609.         const line = document.createElement('div');
  610.         line.className = 'qantas-sc-rbd';
  611.         line.style.fontSize = '0.8em';
  612.         line.style.opacity = '0.75';
  613.         line.style.letterSpacing = '0.05em';
  614.         line.textContent = classes.join(' · ');
  615.         line.title = `Booking class per segment: ${classes.join(', ')}`;
  616.         return line;
  617.     }
  618.  
  619.     // Qantas and partner earn on separate lines above the total, so a Double
  620.     // Status Credits promo (Qantas operated flights only) can be read off
  621.     // directly. The icon codes come from the same sprite the itinerary rows
  622.     // use — img-icon-<carrier>-14, with a default tile for anything unmapped.
  623.     function earnRow(spriteSrc, iconCode, label, text, isTotal) {
  624.         const row = document.createElement('div');
  625.         row.style.display = 'flex';
  626.         row.style.alignItems = 'center';
  627.         row.style.gap = '0.25em';
  628.         row.style.whiteSpace = 'nowrap';
  629.  
  630.         if (isTotal) {
  631.             row.style.borderTop = '1px solid rgba(0, 0, 0, 0.25)';
  632.             row.style.marginTop = '0.15em';
  633.             row.style.paddingTop = '0.15em';
  634.         }
  635.  
  636.         if (iconCode) {
  637.             const icon = document.createElement('img');
  638.             icon.className = `img-icon-${iconCode.toLowerCase()}-14 mr-1`;
  639.             icon.width = 14;
  640.             icon.height = 14;
  641.             icon.alt = label;
  642.             icon.title = label;
  643.             if (spriteSrc) icon.src = spriteSrc;
  644.             row.appendChild(icon);
  645.         } else {
  646.             // Keeps the numbers on the total line aligned with the ones above:
  647.             // same 14px as the sprite, and the same mr-1 the icons carry.
  648.             const spacer = document.createElement('span');
  649.             spacer.className = 'mr-1';
  650.             spacer.style.cssText = 'display:inline-block;width:14px;flex:none';
  651.             spacer.setAttribute('aria-hidden', 'true');
  652.             row.appendChild(spacer);
  653.         }
  654.  
  655.         const value = document.createElement('span');
  656.         value.textContent = text;
  657.         row.appendChild(value);
  658.         return row;
  659.     }
  660.  
  661.     function buildEarnBreakdown(totals, spriteSrc) {
  662.         const box = document.createElement('div');
  663.         box.style.display = 'inline-flex';
  664.         box.style.flexDirection = 'column';
  665.         box.style.textAlign = 'left';
  666.  
  667.         const carriers = totals.partnerCarriers || [];
  668.         // One partner carrier gets its own tail; a mix (e.g. HA+AS) has no
  669.         // single honest icon, so fall back to the generic one.
  670.         const partnerIcon = carriers.length === 1 ? carriers[0].code : 'default';
  671.         const partnerLabel = carriers.length
  672.             ? carriers.map(c => c.name || c.code).join(' / ')
  673.             : 'Partner operated';
  674.  
  675.         // Qantas' own wording uses an en dash for a sector that earns nothing
  676.         const partnerText = (totals.partnerPoints || totals.partnerCredits)
  677.             ? `${group(totals.partnerPoints)} / ${totals.partnerCredits} SC`
  678.             : '– / – SC';
  679.  
  680.         box.appendChild(earnRow(spriteSrc, 'qf', 'Qantas operated',
  681.             `${group(totals.qantasPoints)} / ${totals.qantasCredits} SC`, false));
  682.         box.appendChild(earnRow(spriteSrc, partnerIcon, partnerLabel, partnerText, false));
  683.         box.appendChild(earnRow(spriteSrc, null, 'Total',
  684.             `${group(totals.points)} / ${totals.credits} SC`, true));
  685.  
  686.         return box;
  687.     }
  688.  
  689.     function insertPointsIntoFares() {
  690.         document.querySelectorAll('div.fare-cell').forEach(fareCell => {
  691.             const srSpan = fareCell.querySelector(':scope > span.sr-only');
  692.             const fareValue = fareCell.querySelector('span.fare-value');
  693.  
  694.             if (!srSpan || !fareValue) return;
  695.  
  696.             // Avoid injecting twice, but do re-render against a fresh payload
  697.             // — or against a rate that arrived after the first pass.
  698.             const renderKey = `${earnVersion}.${rateVersion}`;
  699.             const existing = fareValue.querySelector('.qantas-extra-info');
  700.             if (existing) {
  701.                 if (existing.dataset.renderKey === renderKey) return;
  702.                 existing.remove();
  703.             }
  704.  
  705.             // "No seats" cells are still priced in the payload, so the earn
  706.             // lookup happily returns figures for a fare you can't book. The
  707.             // sr-only summary used to say "earned None" for these, which is why
  708.             // nothing showed before the payload became the source.
  709.             if (isSoldOut(fareCell)) return;
  710.  
  711.             const text = srSpan.textContent;
  712.  
  713.             //Qantas Points earned 4,500 for Qantas operated flights and 1,600 for Partners operated flights, Status Credits earned 45 for Qantas operated flights and 25 for Partners operated flights,
  714.  
  715.             const info = document.createElement('div');
  716.             info.className = 'qantas-extra-info';
  717.             info.dataset.renderKey = renderKey;
  718.             info.style.fontSize = '1em';
  719.             info.style.color = '#000';
  720.             info.style.marginTop = '0.25em';
  721.             info.style.textAlign = 'center';
  722.  
  723.             const totals = lookupEarn(fareCell);
  724.  
  725.             // Straight under the local-currency price, so the number the cell
  726.             // leads with means something. Nothing to add on an AUD point of
  727.             // sale, which is the common case.
  728.             if (totals && totals.price && totals.currency !== HOME_CURRENCY) {
  729.                 const converted = buildConvertedPrice(totals.price, totals.currency);
  730.                 if (converted) info.appendChild(converted);
  731.             }
  732.  
  733.             if (totals && (totals.points || totals.credits)) {
  734.                 // Split on whether a partner sector exists, not on whether it
  735.                 // earns. A partner sector earning nothing (e.g. Alaska booking
  736.                 // class G) is worth seeing: it looks identical to an all-Qantas
  737.                 // itinerary otherwise, but sits beside near-identical options
  738.                 // in an earning class.
  739.                 if (totals.partnerCarriers.length) {
  740.                     // Sprite URL is build-versioned, so borrow it from the page
  741.                     const spriteSrc = document.querySelector('img[media-src]')?.src;
  742.                     info.appendChild(buildEarnBreakdown(totals, spriteSrc));
  743.                 } else {
  744.                     // Genuinely all Qantas — one line says it all
  745.                     info.insertAdjacentHTML('beforeend',
  746.                         `${group(totals.points)} / ${group(totals.credits)} SC<br>`);
  747.                 }
  748.  
  749.                 if (totals.bookingClasses.length) {
  750.                     info.appendChild(buildBookingClasses(totals.bookingClasses));
  751.                 }
  752.             } else {
  753.                 // No payload for this cell — fall back to the sr-only summary,
  754.                 // which is correct except on mixed Qantas/partner itineraries.
  755.                 const points = parseEarned(text, 'Qantas Points earned');
  756.                 const credits = parseEarned(text, 'Status Credits earned');
  757.  
  758.                 const anyEarn = points && credits &&
  759.                     [points.qantas, points.partner, credits.qantas, credits.partner].some(earnsAnything);
  760.  
  761.                 if (anyEarn) {
  762.                     if (points.partner === points.qantas && credits.partner === credits.qantas) {
  763.                         info.insertAdjacentHTML('beforeend',
  764.                             `${earnLabel(points.qantas)} / ${earnLabel(credits.qantas)} SC<br>`);
  765.                     } else {
  766.                         info.insertAdjacentHTML('beforeend', `
  767.                     Qantas: ${earnLabel(points.qantas)} / ${earnLabel(credits.qantas)} SC<br>
  768.                     Partner: ${earnLabel(points.partner)} / ${earnLabel(credits.partner)} SC<br>
  769.                 `);
  770.                     }
  771.                 }
  772.             }
  773.  
  774.             // Sold-out and Classic Reward cells earn nothing at all; don't
  775.             // leave an empty div behind.
  776.             if (!info.innerHTML) return;
  777.  
  778.             fareValue.appendChild(info);
  779.         });
  780.     }
  781.  
  782.     // ---- A wider page, on the domestic grid ---------------------------------
  783.     //
  784.     // The grid is laid out inside section.container.page-container, which
  785.     // Qantas caps at 972px. A quarter of that goes on the flight itself and the
  786.     // rest is split between however many fare columns there are, which is tight
  787.     // for the domestic tiles below — five of them on a two-cabin search, each
  788.     // carrying a price, an earn and a booking class. Nothing in the grid is
  789.     // fixed-width, so the room goes straight into the columns.
  790.     //
  791.     // Capped against the viewport rather than set flat, so a laptop gets the
  792.     // screen it has rather than a horizontal scrollbar.
  793.     //
  794.     // Domestic only. The international grid draws just the three to five
  795.     // columns of the one cabin it was asked for, and stretching those over
  796.     // 1800px leaves each fare adrift in its own empty half-screen — there's
  797.     // nothing to spend the width on. grouped-avail-flight-row is the domestic
  798.     // results grid, which makes it both the signal and the reason; it appears a
  799.     // beat after the page does, and the guard above means this just tries again
  800.     // on the next pass until it's there.
  801.  
  802.     const WIDE_PAGE_MAX = 1800;
  803.  
  804.     function widenPageContainer() {
  805.         if (document.getElementById('qantas-sc-width-style')) return;
  806.         if (!document.querySelector('grouped-avail-flight-row')) return;
  807.  
  808.         const style = document.createElement('style');
  809.         style.id = 'qantas-sc-width-style';
  810.         // The date strip doesn't follow the page on its own. It's a fixed
  811.         // viewport onto a longer strip: li.cal-tab-list is pinned to 872px —
  812.         // 7.2 of the 121.111px tabs, both hard-coded in Qantas' sheet under the
  813.         // same min-width: 980px the overrides below match — and the ul inside
  814.         // it clips with overflow: hidden. Widening the page moves the Next
  815.         // arrow out to the edge and leaves those seven days marooned in the
  816.         // middle of it, which is worse than not widening at all.
  817.         //
  818.         // The tabs keep their own width, so the room buys days rather than
  819.         // padding: eleven of them at 1800px instead of seven, out of the
  820.         // fifteen the strip already holds.
  821.         style.textContent = `
  822.             section.container.page-container {
  823.                 max-width: min(${WIDE_PAGE_MAX}px, calc(100vw - 52px)) !important;
  824.             }
  825.             @media (min-width: 980px) {
  826.                 .calendar-container.calendar-container--inline
  827.                     .calendar ul.cal-body > li.cal-tab-list { width: auto !important; }
  828.                 .calendar-container.calendar-container--inline
  829.                     .cal-tab-list > ul { width: 100% !important; }
  830.             }
  831.         `;
  832.         document.head.appendChild(style);
  833.     }
  834.  
  835.     // ---- Domestic grid: every fare, without the clicking -------------------
  836.     //
  837.     // A domestic search draws a different grid to an international one — the
  838.     // grouped-avail-* components, with no div.fare-cell in the results at all —
  839.     // and it shows far less. Each flight row gets one cell per cabin, headed
  840.     // "Economy Cabin" and "Business Cabin" — or "Premium Economy Cabin" and
  841.     // "Business Cabin" on a search made in a premium cabin, which is why the
  842.     // column split below is read from the payload rather than assumed — and
  843.     // each cell carries only the cheapest fare in that cabin ("From $495").
  844.     // The fare families behind it —
  845.     // Red e-Deal, Flex, Classic Plus, Business — appear only once you click the
  846.     // cell, which expands a panel below the row; one cabin at a time, one row at
  847.     // a time. Comparing Flex across six departures is twelve clicks and a lot of
  848.     // scrolling, and the panel's carousel can page fares out of sight when a
  849.     // cabin sells more than a few.
  850.     //
  851.     // None of it is a fetch away: every fare, its price, its earn and its
  852.     // booking class are already in the availability payload the earn capture at
  853.     // the top indexes. So the cabin cell gets rebuilt as a strip of one tile per
  854.     // fare family, and the whole grid reads at a glance.
  855.     //
  856.     // The row's ids carry the payload coordinates:
  857.     //
  858.     //   div#call-to-action_bnd_<bound>_flt_<itemId>
  859.     //     div.fare-columns--item                                   // first cabin
  860.     //       button#left_desktop_group_bnd_<bound>_flt_<itemId>
  861.     //     div.fare-columns--item                                   // second cabin
  862.     //       button#right_desktop_group_bnd_<bound>_flt_<itemId>
  863.     //
  864.     // where <itemId> is the payload's own flights key rather than a row
  865.     // position — a 30-row grid reads flt_1, flt_3, flt_17, flt_5 — which makes
  866.     // the id the whole lookup. The fare panel's own Select buttons are keyed the
  867.     // same way plus the fare code, which is how a tile finds its way back to
  868.     // Qantas' cell:
  869.     //
  870.     //   button#select_btn_bnd_<bound>_flt_<itemId>_reco_<fareCode>
  871.     //
  872.     // Desktop only, by those ids. The mobile layout is a different set of
  873.     // elements and keeps Qantas' own behaviour.
  874.  
  875.     // Qantas' cell is left in place, just hidden, so anything this doesn't
  876.     // handle is one style rule away from being visible again.
  877.     const HIDE_NATIVE_CABIN_CELL = true;
  878.  
  879.     // The cabin is already spelled out in the column heading above the grid, so
  880.     // the tiles drop it: "Economy Classic Plus" reads as "Classic Plus". A name
  881.     // that is nothing but a cabin ("Business") keeps it.
  882.     const CABIN_PREFIX = /^(Premium Economy|Economy|Business|First)\s+/i;
  883.  
  884.     // The "Classic" goes too, leaving a reward tile labelled just PLUS or
  885.     // REWARD. Both are already flagged as rewards by the rosette and by Qantas'
  886.     // red/blue, so the word carries nothing the tile doesn't already say — and
  887.     // the room matters: a three-fare cabin leaves a tile 98px wide, of which
  888.     // "CLASSIC REWARD" and an icon want every one, and a four-fare cabin would
  889.     // leave about 75. The full name is on the tile's tooltip.
  890.     const CLASSIC_PREFIX = /^Classic\s+/i;
  891.  
  892.     // What Qantas calls the fare, in full — the tile's tooltip, where there's
  893.     // room for it.
  894.     function fareName(meta, entry) {
  895.         return (entry && entry.isRewardPlus && meta.rewardPlusName) || meta.name || meta.code;
  896.     }
  897.  
  898.     const moneyFormats = new Map();
  899.  
  900.     function formatMoney(amount, code) {
  901.         if (!(amount >= 0)) return '';
  902.         if (!code) return audAmount.format(Math.round(amount));
  903.  
  904.         let format = moneyFormats.get(code);
  905.         if (!format) {
  906.             try {
  907.                 format = new Intl.NumberFormat('en-AU', {
  908.                     style: 'currency', currency: code, maximumFractionDigits: 0
  909.                 });
  910.             } catch (e) {
  911.                 // An ISO code Intl doesn't know. Better a bare number than
  912.                 // nothing, and the converted line underneath still says AUD.
  913.                 format = audAmount;
  914.             }
  915.             moneyFormats.set(code, format);
  916.         }
  917.         // Qantas rounds to whole units in the grid and so does this, or a tile
  918.         // reads $494.56 against a "From $495" everywhere else on the page.
  919.         return format.format(Math.round(amount));
  920.     }
  921.  
  922.     function fareLabel(meta, entry) {
  923.         const full = fareName(meta, entry);
  924.         return full.replace(CABIN_PREFIX, '').replace(CLASSIC_PREFIX, '') || full;
  925.     }
  926.  
  927.     function injectFareGridStyles() {
  928.         if (document.getElementById('qantas-sc-fare-grid-style')) return;
  929.  
  930.         const style = document.createElement('style');
  931.         style.id = 'qantas-sc-fare-grid-style';
  932.         // The strip has to fill the cabin cell for the tiles to line up under
  933.         // the "Economy Cabin" / "Business Cabin" headings, and the cell is laid
  934.         // out for a single button — hence the width/height and the flex on the
  935.         // cell itself.
  936.         style.textContent = `
  937.             .fare-columns--item.qantas-sc-column {
  938.                 display: flex; align-items: stretch;
  939.             }
  940.             .qantas-sc-fares {
  941.                 display: flex; gap: 4px; width: 100%;
  942.                 padding: 6px 8px; box-sizing: border-box;
  943.             }
  944.             .qantas-sc-fare {
  945.                 flex: 1 1 0; min-width: 0;
  946.                 display: flex; flex-direction: column;
  947.                 /* Top-aligned, not centred: every tile leads with the fare name
  948.                    and then the price, so aligning to the top lines those rows up
  949.                    across the strip. Centring lets a tile with a "2 left" flag
  950.                    push its price out of step with the one beside it. */
  951.                 align-items: center; justify-content: flex-start; gap: 1px;
  952.                 padding: 6px 2px; margin: 0;
  953.                 /* No border of its own — the tiles already sit inside Qantas'
  954.                    own bordered row, and a box around each one reads as a second
  955.                    frame. Transparent rather than none so the hover state below
  956.                    doesn't shift the layout by a pixel. */
  957.                 border: 1px solid transparent; border-radius: 6px;
  958.                 background: #fff; font: inherit; color: #323232;
  959.                 text-align: center; cursor: pointer;
  960.             }
  961.             .qantas-sc-fare:hover, .qantas-sc-fare:focus-visible {
  962.                 border-color: #e40000; outline: none;
  963.             }
  964.             .qantas-sc-fare[disabled] {
  965.                 cursor: default; opacity: 0.4;
  966.                 border-style: dashed; border-color: #d7d7d7;
  967.             }
  968.             .qantas-sc-fare-name {
  969.                 font-size: 12px; line-height: 1.2; color: #5a5a5a;
  970.                 text-transform: uppercase; letter-spacing: 0.02em;
  971.             }
  972.             .qantas-sc-fare-price { font-size: 18px; line-height: 1.25; font-weight: 600; }
  973.             /* The rosette is sized for body copy; the name line is 12px. */
  974.             .qantas-sc-fare-name .icon-classic-rewards-small {
  975.                 font-size: 14px; line-height: 1; margin-right: 2px; vertical-align: -1px;
  976.             }
  977.             .qantas-sc-fare-sub { font-size: 12px; line-height: 1.2; opacity: 0.8; }
  978.             .qantas-sc-fare-earn { font-size: 12px; line-height: 1.3; }
  979.             .qantas-sc-fare-meta {
  980.                 font-size: 11px; line-height: 1.3; opacity: 0.6; letter-spacing: 0.04em;
  981.             }
  982.             .qantas-sc-fare-seats { font-size: 11px; line-height: 1.3; color: #c00000; }
  983.             .qantas-sc-fare .qantas-sc-aud { font-size: 11px; line-height: 1.2; }
  984.             ${HIDE_NATIVE_CABIN_CELL ? '.qantas-sc-column > button.cta-btn { display: none !important; }' : ''}
  985.         `;
  986.         document.head.appendChild(style);
  987.     }
  988.  
  989.     // Classic Plus and Classic Reward are two different ways to pay for the
  990.     // same seat and the tiles have to tell them apart at a glance. Qantas does
  991.     // it by colour — blue for Classic Plus, red for Classic — and the rules
  992.     // behind that are plain one-class utilities on its own stylesheet,
  993.     //
  994.     //   .blue-reward    { color: #29547c }
  995.     //   .classic-reward { color: #e40000 }
  996.     //
  997.     // unscoped by Angular, so borrowing the class beats pinning the hex here:
  998.     // the tiles follow any repaint of Qantas' palette. Same for the rosette,
  999.     // which is a glyph in the qf-icons font the page already loads.
  1000.     //
  1001.     // Note the two names don't line up with the two classes: isRewardPlus is
  1002.     // what says which, not the colour's name.
  1003.     function rewardVariant(entry) {
  1004.         if (!entry) return null;   // no fare, so nothing to be plus or not
  1005.         return entry.isRewardPlus ? 'blue-reward' : 'classic-reward';
  1006.     }
  1007.  
  1008.     function buildFareTile(meta, entry, onOpen) {
  1009.         const tile = document.createElement('button');
  1010.         tile.type = 'button';
  1011.         tile.className = 'qantas-sc-fare';
  1012.  
  1013.         const variant = meta.isReward ? rewardVariant(entry) : null;
  1014.  
  1015.         const name = document.createElement('span');
  1016.         name.className = 'qantas-sc-fare-name';
  1017.  
  1018.         if (meta.isReward) {
  1019.             const rosette = document.createElement('i');
  1020.             rosette.className = `icon icon-classic-rewards-small${variant ? ` ${variant}` : ''}`;
  1021.             rosette.setAttribute('aria-hidden', 'true');
  1022.             name.appendChild(rosette);
  1023.         }
  1024.  
  1025.         // The colour goes on a span of its own rather than on .qantas-sc-fare-name,
  1026.         // which sets a colour of its own — two one-class rules tie on specificity
  1027.         // and this sheet loads last, so it would win and the tile would come out
  1028.         // grey.
  1029.         const nameText = document.createElement('span');
  1030.         if (variant) nameText.className = variant;
  1031.         nameText.textContent = fareLabel(meta, entry);
  1032.         name.appendChild(nameText);
  1033.         tile.appendChild(name);
  1034.  
  1035.         // A family the route sells but this departure doesn't. Keeping the tile
  1036.         // as a placeholder is what holds the columns in line down the grid,
  1037.         // which is the whole point of showing them side by side.
  1038.         if (!entry) {
  1039.             tile.disabled = true;
  1040.             const none = document.createElement('span');
  1041.             none.className = 'qantas-sc-fare-price';
  1042.             none.textContent = '–';
  1043.             tile.appendChild(none);
  1044.             return tile;
  1045.         }
  1046.  
  1047.         const price = document.createElement('span');
  1048.         price.className = `qantas-sc-fare-price${variant ? ` ${variant}` : ''}`;
  1049.  
  1050.         if (meta.isReward && entry.rewardPoints > 0) {
  1051.             // Priced in points plus a cash component, the way Qantas' own cell
  1052.             // puts it: "95,700 PTS + $103".
  1053.             price.textContent = `${group(entry.rewardPoints)} pts`;
  1054.             tile.appendChild(price);
  1055.  
  1056.             if (entry.tax > 0) {
  1057.                 const plus = document.createElement('span');
  1058.                 plus.className = 'qantas-sc-fare-sub';
  1059.                 plus.textContent = `+ ${formatMoney(entry.tax, entry.currency)}`;
  1060.                 tile.appendChild(plus);
  1061.  
  1062.                 const converted = entry.currency !== HOME_CURRENCY
  1063.                     ? buildConvertedPrice(entry.tax, entry.currency) : null;
  1064.                 if (converted) tile.appendChild(converted);
  1065.             }
  1066.         } else {
  1067.             price.textContent = formatMoney(entry.price, entry.currency);
  1068.             tile.appendChild(price);
  1069.  
  1070.             // A domestic search off an overseas point of sale prices in that
  1071.             // market's currency, same as an international one.
  1072.             const converted = entry.currency !== HOME_CURRENCY
  1073.                 ? buildConvertedPrice(entry.price, entry.currency) : null;
  1074.             if (converted) tile.appendChild(converted);
  1075.         }
  1076.  
  1077.         // A Classic Reward fare earns nothing, so there's no line to draw.
  1078.         if (entry.points || entry.credits) {
  1079.             const earn = document.createElement('span');
  1080.             earn.className = 'qantas-sc-fare-earn';
  1081.             earn.textContent = `${group(entry.points)} / ${group(entry.credits)} SC`;
  1082.             earn.title = 'Qantas Points / Status Credits earned';
  1083.             tile.appendChild(earn);
  1084.         }
  1085.  
  1086.         if (entry.bookingClasses.length) {
  1087.             const classes = document.createElement('span');
  1088.             classes.className = 'qantas-sc-fare-meta';
  1089.             classes.textContent = entry.bookingClasses.join('·');
  1090.             classes.title = `Booking class per segment: ${entry.bookingClasses.join(', ')}`;
  1091.             tile.appendChild(classes);
  1092.         }
  1093.  
  1094.         if (entry.seatsLeft > 0) {
  1095.             const seats = document.createElement('span');
  1096.             seats.className = 'qantas-sc-fare-seats';
  1097.             seats.textContent = `${entry.seatsLeft} left`;
  1098.             seats.title = `${entry.seatsLeft} left at this price`;
  1099.             tile.appendChild(seats);
  1100.         }
  1101.  
  1102.         const label = `${fareName(meta, entry)} — open this fare`;
  1103.         tile.title = label;
  1104.         tile.setAttribute('aria-label', label);
  1105.         tile.addEventListener('click', onOpen);
  1106.         return tile;
  1107.     }
  1108.  
  1109.     // A tile opens Qantas' own panel at the fare it names rather than selecting
  1110.     // it — one click to the fare conditions, bags and change fees, and no way
  1111.     // for a stray click in here to put a fare in the cart.
  1112.     function openNativeFare(item, native, boundIndex, itemId, fareCode) {
  1113.         // The cabin cell toggles its panel, so clicking one that's already open
  1114.         // would shut it. Qantas marks the open column on the cell.
  1115.         if (!item.classList.contains('column-selected')) native.click();
  1116.  
  1117.         // The panel animates in, so there's nothing to scroll to yet.
  1118.         setTimeout(() => {
  1119.             const select = document.getElementById(
  1120.                 `select_btn_bnd_${boundIndex}_flt_${itemId}_reco_${fareCode}`);
  1121.             const cell = (select && select.closest('.fare-cell')) || select;
  1122.             if (cell) cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
  1123.         }, 450);
  1124.     }
  1125.  
  1126.     function insertFaresIntoGroupedRows() {
  1127.         const rows = document.querySelectorAll('div[id^="call-to-action_bnd_"]');
  1128.         if (!rows.length) return;   // international layout, or results not drawn yet
  1129.  
  1130.         injectFareGridStyles();
  1131.  
  1132.         // Same two counters the international grid re-renders against: a new
  1133.         // search restates every fare, and a rate can land after the first pass.
  1134.         const renderKey = `${earnVersion}.${rateVersion}`;
  1135.  
  1136.         rows.forEach(cta => {
  1137.             const coords = cta.id.match(/^call-to-action_bnd_(\d+)_flt_(\d+)$/);
  1138.             if (!coords) return;
  1139.             const [, boundIndex, itemId] = coords;
  1140.  
  1141.             const families = boundFareFamilies.get(boundIndex);
  1142.             if (!families || !families.length) return;
  1143.  
  1144.             cta.querySelectorAll('.fare-columns--item').forEach(item => {
  1145.                 // Qantas' own "No seats" cell — no fares behind it, and nothing
  1146.                 // to expand if a tile were clicked.
  1147.                 if (item.classList.contains('unavailable')) return;
  1148.  
  1149.                 const native = item.querySelector('button.cta-btn');
  1150.                 const side = native && native.id.match(/^(left|right)_desktop_group_/);
  1151.                 if (!side) return;
  1152.  
  1153.                 const columnFamilies = families.filter(f => f.firstColumn === (side[1] === 'left'));
  1154.                 if (!columnFamilies.length) return;
  1155.  
  1156.                 const existing = item.querySelector('.qantas-sc-fares');
  1157.                 if (existing) {
  1158.                     if (existing.dataset.renderKey === renderKey) return;
  1159.                     existing.remove();
  1160.                 }
  1161.  
  1162.                 const strip = document.createElement('div');
  1163.                 strip.className = 'qantas-sc-fares';
  1164.                 strip.dataset.renderKey = renderKey;
  1165.  
  1166.                 let priced = 0;
  1167.                 columnFamilies.forEach(meta => {
  1168.                     const entry = earnTotals.get(`${boundIndex}_${itemId}_${meta.code}`);
  1169.                     if (entry) priced++;
  1170.                     strip.appendChild(buildFareTile(meta, entry,
  1171.                         () => openNativeFare(item, native, boundIndex, itemId, meta.code)));
  1172.                 });
  1173.  
  1174.                 // Nothing indexed for this flight. Qantas' cell still has a
  1175.                 // price in it, so leave it alone rather than cover it with a
  1176.                 // row of dashes.
  1177.                 if (!priced) return;
  1178.  
  1179.                 item.appendChild(strip);
  1180.                 item.classList.add('qantas-sc-column');
  1181.             });
  1182.         });
  1183.     }
  1184.  
  1185.     // ---- Always-visible in-flight amenities -------------------------------
  1186.     //
  1187.     // Qantas only renders <flight-amenities> inside the "flight details" modal,
  1188.     // so the amenity list is missing from the results grid until you click a
  1189.     // price. The data it renders from is already client side though: the app
  1190.     // caches it in localStorage as
  1191.     //
  1192.     //   flightAmenityMapping    ORIG-DEST-CARRIER-NUMBER-YYYYMMDD-CABIN -> [amenityKey]
  1193.     //   flightAmenityDictionary amenityKey -> { type, displayText, icon }
  1194.     //
  1195.     // both shaped as [{ tab_id, expirationDate, data: [[key, value], ...] }].
  1196.     // So we can rebuild the component ourselves and drop one into every
  1197.     // segment of every itinerary, no clicking required.
  1198.  
  1199.     // Icons only, on one line, with the wording on hover. Set to false for the
  1200.     // full labelled list the modal shows.
  1201.     const COMPACT_AMENITIES = true;
  1202.  
  1203.     // Qantas caps the modal at six and drops anything without a real icon.
  1204.     const MAX_AMENITIES = 6;
  1205.     const DEFAULT_ICON = 'amenity-ic-default';
  1206.  
  1207.     let amenityData = null;
  1208.  
  1209.     function loadAmenityData() {
  1210.         const tabId = new URLSearchParams(location.search).get('TAB_ID');
  1211.  
  1212.         // Prefer entries written by this booking tab; other tabs' entries are
  1213.         // stale searches that can collide on flight number.
  1214.         const entriesFor = key => {
  1215.             let parsed;
  1216.             try {
  1217.                 parsed = JSON.parse(localStorage.getItem(key)) || [];
  1218.             } catch (e) {
  1219.                 return [];
  1220.             }
  1221.             const now = Date.now();
  1222.             const live = parsed.filter(e => !e.expirationDate || e.expirationDate > now);
  1223.             const mine = live.filter(e => e.tab_id === tabId);
  1224.             return (mine.length ? mine : live).flatMap(e => e.data || []);
  1225.         };
  1226.  
  1227.         const dict = new Map(entriesFor('flightAmenityDictionary'));
  1228.         const map = new Map(entriesFor('flightAmenityMapping'));
  1229.         return dict.size && map.size ? { dict, map } : null;
  1230.     }
  1231.  
  1232.     // Angular scopes the component's CSS with a per-build attribute, e.g.
  1233.     // .flight-amenity-text[_ngcontent-ng-c3048682860]. Dig it out of the
  1234.     // stylesheets so our markup picks up the real styling.
  1235.     let styleToken;
  1236.  
  1237.     function amenityStyleToken() {
  1238.         if (styleToken !== undefined) return styleToken;
  1239.         styleToken = null;
  1240.         for (const sheet of document.styleSheets) {
  1241.             let rules;
  1242.             try {
  1243.                 rules = sheet.cssRules;
  1244.             } catch (e) {
  1245.                 continue; // cross-origin sheet
  1246.             }
  1247.             for (const rule of rules) {
  1248.                 const match = rule.selectorText &&
  1249.                     rule.selectorText.match(/\.flight-amenity-text\[(_ngcontent-[a-z0-9-]+)\]/);
  1250.                 if (match) {
  1251.                     styleToken = match[1];
  1252.                     return styleToken;
  1253.                 }
  1254.             }
  1255.         }
  1256.         return styleToken;
  1257.     }
  1258.  
  1259.     function injectAmenityStyles() {
  1260.         if (document.getElementById('qantas-sc-amenity-style')) return;
  1261.  
  1262.         const style = document.createElement('style');
  1263.         style.id = 'qantas-sc-amenity-style';
  1264.         // Three things to undo from Qantas' own styling: the amenity rows use
  1265.         // Bootstrap's .container class (full width); .flight-amenity-icon
  1266.         // carries a margin-top of -4px that collapses the line to 12px against
  1267.         // a 16px sprite; and the sprite classes pin the box to 16x16 with
  1268.         // min/max-width/height.
  1269.         //
  1270.         // That last one crops the aircraft tiles. Measured off the sheet, every
  1271.         // img-amenity-ic-plane-* tile is 18px tall (the others are 14px or
  1272.         // less), so a 16px box cuts the bottom 2px — exactly the strip holding
  1273.         // the aircraft type text. Qantas sizes these at 18px via
  1274.         // .flight-amenity-icon.mr-1.ng-star-inserted, but that needs their
  1275.         // Angular marker class, so set it here instead. min/max have to be
  1276.         // overridden too, or they win over width/height.
  1277.         style.textContent = `
  1278.             flight-amenities.qantas-sc-amenities .flight-amenity-icon {
  1279.                 width: 18px; height: 18px;
  1280.                 min-width: 18px; max-width: 18px;
  1281.                 min-height: 18px; max-height: 18px;
  1282.             }
  1283.             flight-amenities.qantas-sc-amenities { display: block; font-size: 14px; line-height: 1.35; margin-top: 0.5em; }
  1284.             flight-amenities.qantas-sc-amenities .title { font-weight: 600; margin: 0 0 0.15em; }
  1285.             flight-amenities.qantas-sc-amenities .container { display: flex; align-items: center; }
  1286.             flight-amenities.qantas-sc-amenities .flight-amenity-icon { flex: none; }
  1287.             flight-amenities.qantas-sc-amenities.compact { display: flex; align-items: center; gap: 0.4em; line-height: 1; }
  1288.             flight-amenities.qantas-sc-amenities.compact .container { flex: 0 0 auto; width: auto; margin: 0; padding: 0; }
  1289.             flight-amenities.qantas-sc-amenities.compact .container .flight-amenity-icon { margin: 0; cursor: help; }
  1290.             flight-amenities.qantas-sc-amenities.compact .title,
  1291.             flight-amenities.qantas-sc-amenities.compact .flight-amenity-text { display: none; }
  1292.  
  1293.             /* The domestic row's own grid already reserves a full-width track
  1294.                for this, named and sitting at 0px for want of anything in it. */
  1295.             .info-details .qantas-sc-amenity-strips {
  1296.                 grid-area: flight-amenities;
  1297.                 display: flex; flex-direction: column; gap: 2px;
  1298.                 margin-top: 0.25em; min-width: 0;
  1299.             }
  1300.             .info-details .qantas-sc-amenity-strips flight-amenities.qantas-sc-amenities {
  1301.                 margin-top: 0;
  1302.             }
  1303.         `;
  1304.         document.head.appendChild(style);
  1305.     }
  1306.  
  1307.     // The grid only gives us a flight number, so match on carrier + number and
  1308.     // disambiguate with the aircraft icon the row already carries as a class
  1309.     // (e.g. "segment e2e-amenity-ic-plane-b-737-flat"), then by cabin.
  1310.     function lookupAmenities(segment, flightNumber, preferredCabin) {
  1311.         const { dict, map } = amenityData;
  1312.  
  1313.         const parts = flightNumber.match(/^([A-Z0-9]{2})\s*0*(\d+)$/);
  1314.         if (!parts) return null;
  1315.         const needle = `-${parts[1]}-${parts[2]}-`;
  1316.  
  1317.         const aircraftIcon = [...segment.classList]
  1318.             .map(c => c.replace(/^e2e-/, ''))
  1319.             .find(c => c.startsWith('amenity-ic-plane'));
  1320.  
  1321.         let keys = [...map.keys()].filter(k => k.includes(needle));
  1322.         if (aircraftIcon) {
  1323.             // Case-insensitive: the row's class is lower-cased but the
  1324.             // dictionary isn't always (see the A321neo note in buildAmenities).
  1325.             const sameAircraft = keys.filter(k =>
  1326.                 (map.get(k) || []).some(id => dict.get(id) &&
  1327.                     dict.get(id).icon.toLowerCase() === aircraftIcon.toLowerCase()));
  1328.             if (sameAircraft.length) keys = sameAircraft;
  1329.         }
  1330.  
  1331.         const key = keys.find(k => k.endsWith(`-${preferredCabin}`)) || keys[0];
  1332.         if (!key) return null;
  1333.  
  1334.         const items = (map.get(key) || [])
  1335.             .map(id => dict.get(id))
  1336.             .filter(item => item && item.icon !== DEFAULT_ICON)
  1337.             .slice(0, MAX_AMENITIES);
  1338.  
  1339.         return items.length ? items : null;
  1340.     }
  1341.  
  1342.     function buildAmenities(items, spriteSrc) {
  1343.         const token = amenityStyleToken();
  1344.         const scoped = el => {
  1345.             if (token) el.setAttribute(token, '');
  1346.             return el;
  1347.         };
  1348.  
  1349.         const root = document.createElement('flight-amenities');
  1350.         root.className = 'qantas-sc-amenities' + (COMPACT_AMENITIES ? ' compact' : '');
  1351.         if (token) root.setAttribute(token.replace('_ngcontent', '_nghost'), '');
  1352.  
  1353.         const title = scoped(document.createElement('div'));
  1354.         title.className = 'title';
  1355.         title.textContent = 'In-flight Amenities';
  1356.         root.appendChild(title);
  1357.  
  1358.         items.forEach(item => {
  1359.             const container = scoped(document.createElement('div'));
  1360.             container.className = 'container';
  1361.             container.title = item.displayText;
  1362.  
  1363.             // Qantas hides the icon from screen readers and puts the raw
  1364.             // amenity key in an sr-only span; alt text is more use to both
  1365.             // screen readers and hover, especially with the label collapsed.
  1366.             const icon = scoped(document.createElement('img'));
  1367.             // Lower-cased because the dictionary doesn't always agree with the
  1368.             // sprite sheet on case: the A321neo comes through as
  1369.             // "amenity-ic-plane-a-32Q-flat" while the only rule for it is
  1370.             // .img-amenity-ic-plane-a-32q-flat, and class matching is
  1371.             // case-sensitive — so that one tile silently rendered blank.
  1372.             icon.className = `flight-amenity-icon img-${item.icon.toLowerCase()}` +
  1373.                 (COMPACT_AMENITIES ? '' : ' mr-1'); // the strip spaces with gap instead
  1374.             icon.alt = item.displayText;
  1375.             icon.title = item.displayText;
  1376.             if (spriteSrc) icon.src = spriteSrc;
  1377.  
  1378.             const text = scoped(document.createElement('span'));
  1379.             text.className = 'flight-amenity-text';
  1380.             text.textContent = item.displayText;
  1381.  
  1382.             container.append(icon, text);
  1383.             root.appendChild(container);
  1384.         });
  1385.  
  1386.         return root;
  1387.     }
  1388.  
  1389.     function preferredCabin() {
  1390.         const label = (document.querySelector('cabin-selector')?.innerText || '').toLowerCase();
  1391.         if (label.includes('premium')) return 'PREMIUM';
  1392.         if (label.includes('business')) return 'BUSINESS';
  1393.         if (label.includes('first')) return 'FIRST';
  1394.         return 'ECON';
  1395.     }
  1396.  
  1397.     function insertAmenitiesIntoSegments() {
  1398.         const segments = document.querySelectorAll('upsell-segment-details .segment');
  1399.         if (!segments.length) return;
  1400.  
  1401.         if (!amenityData) amenityData = loadAmenityData();
  1402.         if (!amenityData) return; // written a little after the results render
  1403.  
  1404.         injectAmenityStyles();
  1405.  
  1406.         // The sprite URL is build-versioned, so borrow it from the page.
  1407.         const spriteSrc = document.querySelector('img[media-src]')?.src;
  1408.         const cabin = preferredCabin();
  1409.         let missed = false;
  1410.  
  1411.         segments.forEach(segment => {
  1412.             // Selecting a fare makes Qantas render its own amenity strip per
  1413.             // segment — as a sibling of .segment, directly under
  1414.             // upsell-segment-details, which is why checking inside .segment
  1415.             // isn't enough to notice it. Stand down where theirs is present,
  1416.             // and pull ours back out if it turned up after we'd injected.
  1417.             //
  1418.             // "Present" has to mean "actually rendered something": on partner
  1419.             // segments (e.g. AA) Qantas emits an empty <flight-amenities> with
  1420.             // no icons at all, and treating that as theirs leaves the row
  1421.             // blank when we do have the data for it.
  1422.             const details = segment.closest('upsell-segment-details') || segment;
  1423.             const ours = details.querySelector('flight-amenities.qantas-sc-amenities');
  1424.             const nativeShown = [...details.querySelectorAll('flight-amenities:not(.qantas-sc-amenities)')]
  1425.                 .some(strip => strip.querySelector('img'));
  1426.  
  1427.             if (nativeShown) {
  1428.                 if (ours) ours.remove();
  1429.                 return;
  1430.             }
  1431.  
  1432.             // Avoid injecting twice
  1433.             if (ours) return;
  1434.  
  1435.             const flightNumber = segment.querySelector('.e2e-flight-number')?.textContent?.trim();
  1436.             if (!flightNumber) return;
  1437.  
  1438.             const items = lookupAmenities(segment, flightNumber, cabin);
  1439.             if (!items) {
  1440.                 missed = true;
  1441.                 return;
  1442.             }
  1443.  
  1444.             segment.appendChild(buildAmenities(items, spriteSrc));
  1445.         });
  1446.  
  1447.         // A new search writes fresh localStorage entries under the same keys;
  1448.         // drop the cache so the next pass picks them up.
  1449.         if (missed) amenityData = null;
  1450.     }
  1451.  
  1452.     // ---- Amenities on the domestic grid -----------------------------------
  1453.     //
  1454.     // The domestic grid has no upsell-segment-details to hang anything off, and
  1455.     // its rows carry no aircraft icon to disambiguate a flight number by — so
  1456.     // the guesswork above doesn't transfer. It doesn't need to: the availability
  1457.     // payload states every part of the cache key outright, so a row can ask for
  1458.     // its icons by name.
  1459.     //
  1460.     //   bounds[b].listItineraries.itineraries[].segments[]
  1461.     //     { beginLocationCode: "SYD3", endLocationCode: "MEL1",
  1462.     //       cachedAirlineCode: "QF", flightNumber: "443",
  1463.     //       beginDate: 1792845600000 }
  1464.     //
  1465.     // Two traps in building the key from that. The location codes carry a
  1466.     // terminal qualifier — "SYD3", "MEL1", and "AVVD" for Avalon, so it isn't
  1467.     // always a digit — which makes the airport the first three characters and
  1468.     // nothing else. And beginDate is local wall-clock stored as a UTC instant:
  1469.     // getUTCHours() gives the departure time the grid prints (08:05), while
  1470.     // reading it in the browser's own zone shifts it (18:35 in Adelaide) and
  1471.     // takes the date with it on any morning flight. So the date has to be read
  1472.     // in UTC.
  1473.     //
  1474.     // Verified against the cache on a 27-flight SYD-ADL search: 61 of 61
  1475.     // segments matched, none missed.
  1476.     //
  1477.     // Where it goes is Qantas' own decision. .info-details is a grid whose
  1478.     // fourth row is already named flight-amenities, spanning all four columns
  1479.     // and sitting at 0px because nothing is ever put in it:
  1480.     //
  1481.     //   "departure-loc     departure-loc          arrival-loc   arrival-loc"
  1482.     //   "departure-time    travel-icon-container  ...           arrival-time"
  1483.     //   "code              code                   code          segment-duration"
  1484.     //   "flight-amenities  flight-amenities       ...           flight-amenities"
  1485.  
  1486.     const flightSegments = new Map();   // `${bound}_${itemId}` -> [key prefix]
  1487.  
  1488.     // ECON first: it's the cabin most searches are in, and the aircraft and
  1489.     // wi-fi lines read the same whichever cabin's entry answers.
  1490.     const AMENITY_CABINS = ['ECON', 'PREMIUM', 'BUSINESS', 'FIRST'];
  1491.  
  1492.     const twoDigits = n => String(n).padStart(2, '0');
  1493.  
  1494.     function amenityKeyPrefix(segment) {
  1495.         const airport = code => String(code || '').slice(0, 3);
  1496.         const carrier = segment.cachedAirlineCode || segment.codeForIcon || segment.airline?.code;
  1497.         const number = Number(segment.flightNumber);
  1498.         const departs = new Date(segment.beginDate);
  1499.  
  1500.         if (!carrier || !(number > 0) || !Number.isFinite(departs.getTime())) return null;
  1501.         if (airport(segment.beginLocationCode).length < 3) return null;
  1502.         if (airport(segment.endLocationCode).length < 3) return null;
  1503.  
  1504.         const date = `${departs.getUTCFullYear()}${twoDigits(departs.getUTCMonth() + 1)}` +
  1505.             `${twoDigits(departs.getUTCDate())}`;
  1506.  
  1507.         return `${airport(segment.beginLocationCode)}-${airport(segment.endLocationCode)}` +
  1508.             `-${carrier}-${number}-${date}-`;
  1509.     }
  1510.  
  1511.     function indexSegments(boundIndex, bound) {
  1512.         (bound.listItineraries?.itineraries || []).forEach(itinerary => {
  1513.             const prefixes = (itinerary.segments || []).map(amenityKeyPrefix);
  1514.  
  1515.             // All or nothing: a partial itinerary would draw a strip for some
  1516.             // legs and silently drop the rest, which reads as "this leg has no
  1517.             // wi-fi" rather than "we couldn't tell".
  1518.             if (prefixes.length && prefixes.every(Boolean)) {
  1519.                 flightSegments.set(`${boundIndex}_${itinerary.itemId}`, prefixes);
  1520.             }
  1521.         });
  1522.     }
  1523.  
  1524.     function lookupAmenitiesByKey(prefix, cabin) {
  1525.         const { dict, map } = amenityData;
  1526.  
  1527.         const order = [cabin, ...AMENITY_CABINS.filter(c => c !== cabin)];
  1528.         const ids = order.map(c => map.get(prefix + c)).find(Boolean);
  1529.         if (!ids) return null;
  1530.  
  1531.         const items = ids
  1532.             .map(id => dict.get(id))
  1533.             .filter(item => item && item.icon !== DEFAULT_ICON)
  1534.             .slice(0, MAX_AMENITIES);
  1535.  
  1536.         return items.length ? items : null;
  1537.     }
  1538.  
  1539.     function insertAmenitiesIntoGroupedRows() {
  1540.         const rows = document.querySelectorAll('div[id^="flight_row_bnd_"]');
  1541.         if (!rows.length) return;   // international layout, or nothing drawn yet
  1542.  
  1543.         if (!amenityData) amenityData = loadAmenityData();
  1544.         if (!amenityData) return;   // written a little after the results render
  1545.  
  1546.         injectAmenityStyles();
  1547.  
  1548.         // The sprite URL is build-versioned, so borrow it from the page.
  1549.         const spriteSrc = document.querySelector('img[media-src]')?.src;
  1550.         const cabin = preferredCabin();
  1551.         let missed = false;
  1552.  
  1553.         rows.forEach(row => {
  1554.             const coords = row.id.match(/^flight_row_bnd_(\d+)_flt_(\d+)$/);
  1555.             if (!coords) return;
  1556.  
  1557.             const prefixes = flightSegments.get(`${coords[1]}_${coords[2]}`);
  1558.             if (!prefixes) return;
  1559.  
  1560.             // A connection draws one .info-details per leg, each with its own
  1561.             // flight number and its own reserved amenities track, so every leg
  1562.             // gets its own strip in its own block. Putting them all in the
  1563.             // first block — which is what one querySelector gets you — stacks a
  1564.             // connection's two aircraft under its first leg.
  1565.             const hosts = [...row.querySelectorAll('.info-details')];
  1566.  
  1567.             // Paired by flight number rather than by position: the two lists
  1568.             // should agree, but the block says outright which leg it is.
  1569.             // Matched prefixes are consumed so a repeat still pairs in order.
  1570.             const unused = prefixes.slice();
  1571.  
  1572.             hosts.forEach((host, index) => {
  1573.                 const flight = host.querySelector('.e2e-flight-number')?.textContent?.trim() || '';
  1574.                 const parts = flight.match(/^([A-Z0-9]{2})\s*0*(\d+)$/);
  1575.  
  1576.                 let at = parts
  1577.                     ? unused.findIndex(prefix => prefix.includes(`-${parts[1]}-${Number(parts[2])}-`))
  1578.                     : -1;
  1579.                 if (at < 0) at = unused.indexOf(prefixes[index]);
  1580.                 if (at < 0) return;
  1581.  
  1582.                 const prefix = unused.splice(at, 1)[0];
  1583.  
  1584.                 // Expanding a fare makes Qantas render its own strip into this
  1585.                 // same .info-details, and both land in the row's one
  1586.                 // flight-amenities grid track — so the two sets stack a few
  1587.                 // pixels apart and read as fuzzy. Theirs wins: it's the same
  1588.                 // six icons from the same dictionary (verified leg by leg on
  1589.                 // SYD-CNS, aircraft tile included), so standing down loses
  1590.                 // nothing. "Present" has to mean "actually rendered": partner
  1591.                 // segments get an empty <flight-amenities>, and treating that
  1592.                 // as theirs would blank a row we do have data for. The panel
  1593.                 // can't be collapsed again, but if it ever goes, the next pass
  1594.                 // puts ours back.
  1595.                 const theirs = [...host.querySelectorAll('flight-amenities:not(.qantas-sc-amenities)')]
  1596.                     .some(strip => strip.querySelector('img'));
  1597.                 if (theirs) {
  1598.                     host.querySelector('.qantas-sc-amenity-strips')?.remove();
  1599.                     return;
  1600.                 }
  1601.  
  1602.                 // Changing the cabin filter changes which entry answers, so the
  1603.                 // cabin is the render key here rather than the payload version.
  1604.                 const existing = host.querySelector('.qantas-sc-amenity-strips');
  1605.                 if (existing) {
  1606.                     if (existing.dataset.cabin === cabin) return;
  1607.                     existing.remove();
  1608.                 }
  1609.  
  1610.                 const items = lookupAmenitiesByKey(prefix, cabin);
  1611.                 if (!items) {
  1612.                     missed = true;
  1613.                     return;
  1614.                 }
  1615.  
  1616.                 const box = document.createElement('div');
  1617.                 box.className = 'qantas-sc-amenity-strips';
  1618.                 box.dataset.cabin = cabin;
  1619.                 box.appendChild(buildAmenities(items, spriteSrc));
  1620.                 host.appendChild(box);
  1621.             });
  1622.         });
  1623.  
  1624.         // A new search writes fresh localStorage entries under the same keys;
  1625.         // drop the cache so the next pass picks them up.
  1626.         if (missed) amenityData = null;
  1627.     }
  1628.  
  1629.     // ---- "Select travel dates" calendar on the search widget --------------
  1630.     //
  1631.     // The date picker on qantas.com's Book a flight widget puts a lowest fare
  1632.     // under each date, in the point-of-sale currency — when the route has
  1633.     // market pricing to show, which plenty don't. That's why the prices are
  1634.     // there on some searches and not others; nothing to work around, just a
  1635.     // reason not to assume a cell has one.
  1636.     //
  1637.     // Different site, different app: no Angular, no availability payload. The
  1638.     // prices come from
  1639.     //
  1640.     //   GET https://api.qantas.com/market-pricing/mpp/v1/calendar
  1641.     //     -> { currency: "ZAR", isClassic: false,
  1642.     //          days: [ { departureDate: "140926",          // DDMMYY
  1643.     //                    totalPrice: 68031.8,
  1644.     //                    roundedOffTotalPrice: "68,032" } ] }
  1645.     //
  1646.     // and over fetch rather than XMLHttpRequest, so they need their own hook.
  1647.     // The DOM hands the join key straight over: each price sits in a
  1648.     // <div id="140926-price">, keyed by exactly the departureDate the API
  1649.     // used. The classes around it are emotion hashes that change every build,
  1650.     // so the id and the day button's data-testid are all this leans on.
  1651.  
  1652.     const MPP_CALENDAR = '/market-pricing/mpp/v1/calendar';
  1653.  
  1654.     const widgetPrices = new Map();  // DDMMYY -> { price, currency, shown }
  1655.  
  1656.     function indexWidgetCalendar(text) {
  1657.         let data;
  1658.         try {
  1659.             data = JSON.parse(text);
  1660.         } catch (e) {
  1661.             return;
  1662.         }
  1663.         if (!Array.isArray(data?.days)) return;
  1664.  
  1665.         let added = 0;
  1666.         data.days.forEach(day => {
  1667.             if (!day?.departureDate || !(day.totalPrice > 0)) return;
  1668.  
  1669.             widgetPrices.set(String(day.departureDate), {
  1670.                 price: day.totalPrice,
  1671.                 currency: data.currency,
  1672.                 // What the cell prints, so the render can check it's looking
  1673.                 // at the day it thinks it is
  1674.                 shown: parseDisplayedAmount(day.roundedOffTotalPrice)
  1675.             });
  1676.             added++;
  1677.         });
  1678.  
  1679.         if (added) rerender();
  1680.     }
  1681.  
  1682.     function installWidgetCapture() {
  1683.         const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  1684.         const fetch0 = pageWindow.fetch;
  1685.         if (typeof fetch0 !== 'function') return;
  1686.  
  1687.         pageWindow.fetch = function (...args) {
  1688.             const url = String(args[0]?.url || args[0] || '');
  1689.             const response = fetch0.apply(this, args);
  1690.  
  1691.             if (url.includes(MPP_CALENDAR)) {
  1692.                 // A clone, so the app still gets to read its own body.
  1693.                 response
  1694.                     .then(result => result.clone().text())
  1695.                     .then(indexWidgetCalendar)
  1696.                     .catch(() => { /* the app deals with its own failures */ });
  1697.             }
  1698.  
  1699.             return response;
  1700.         };
  1701.     }
  1702.  
  1703.     function insertPricesIntoWidgetCalendar() {
  1704.         // The observer is watching every page on qantas.com, most of which
  1705.         // have no picker on them. Nothing to look for until the API has
  1706.         // answered, and it only answers once the picker is opened.
  1707.         if (!widgetPrices.size) return;
  1708.  
  1709.         document.querySelectorAll('div[id$="-price"]').forEach(priceEl => {
  1710.             const key = priceEl.id.slice(0, -'-price'.length);
  1711.             if (!/^\d{6}$/.test(key)) return;
  1712.  
  1713.             const day = widgetPrices.get(key);
  1714.             const cell = priceEl.closest('button');
  1715.             if (!cell) return;
  1716.  
  1717.             const existing = cell.querySelector('.qantas-sc-aud');
  1718.  
  1719.             // An empty cell is a day with no fare. A mismatch means the cell
  1720.             // has been repainted for a date the entry isn't for — the picker
  1721.             // reuses these ids across a return trip's two calendars.
  1722.             const usable = day && day.currency !== HOME_CURRENCY &&
  1723.                 parseDisplayedAmount(priceEl.textContent) === day.shown;
  1724.  
  1725.             const line = usable ? buildConvertedPrice(day.price, day.currency) : null;
  1726.             if (!line) {
  1727.                 if (existing) existing.remove();
  1728.                 return;
  1729.             }
  1730.  
  1731.             if (existing) {
  1732.                 if (existing.textContent === line.textContent) return;
  1733.                 existing.remove();
  1734.             }
  1735.  
  1736.             // The cell is a 104px flex column — date, price, and room for one
  1737.             // more line. The price above it is 18px.
  1738.             line.style.fontSize = '11px';
  1739.             line.style.lineHeight = '1.4';
  1740.             line.style.textAlign = 'center';
  1741.             line.style.whiteSpace = 'nowrap';
  1742.             cell.appendChild(line);
  1743.         });
  1744.     }
  1745.  
  1746.     let pending = false;
  1747.  
  1748.     function render() {
  1749.         if (onBookingSite) {
  1750.             widenPageContainer();
  1751.             insertPointsIntoFares();
  1752.             insertFaresIntoGroupedRows();
  1753.             insertPricesIntoCalendar();
  1754.             insertAmenitiesIntoSegments();
  1755.             insertAmenitiesIntoGroupedRows();
  1756.         } else {
  1757.             insertPricesIntoWidgetCalendar();
  1758.         }
  1759.     }
  1760.  
  1761.     function refresh() {
  1762.         if (onBookingSite) removeddT26();
  1763.         render();
  1764.     }
  1765.  
  1766.     const observer = new MutationObserver(() => {
  1767.         if (pending) return;
  1768.         pending = true;
  1769.         requestAnimationFrame(() => {
  1770.             pending = false;
  1771.             refresh();
  1772.         });
  1773.     });
  1774.  
  1775.     function startWatchingTheDom() {
  1776.         observer.observe(document.body, { childList: true, subtree: true });
  1777.         refresh();
  1778.     }
  1779.  
  1780.     // The request hooks have to go in immediately — on the booking site the
  1781.     // availability request is already on its way. Everything else waits for a
  1782.     // body to exist.
  1783.     if (onBookingSite) {
  1784.         installEarnCapture();
  1785.     } else {
  1786.         installWidgetCapture();
  1787.     }
  1788.  
  1789.     if (document.readyState === 'loading') {
  1790.         document.addEventListener('DOMContentLoaded', startWatchingTheDom, { once: true });
  1791.     } else {
  1792.         startWatchingTheDom();
  1793.     }
  1794.  
  1795. })();
  1796.  
  1797.  
Advertisement
Add Comment
Please, Sign In to add comment