Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- // Hi AFF friends!
- //
- // I've moved this script to https://greasyfork.org/en/scripts/595609-qantas-sc-display .
- // You'll find the newest version there, and they provide a one-click install.
- //
- // joelby
- // ==UserScript==
- // @name Qantas SC display
- // @namespace https://book.qantas.com/
- // @version 2026-09-13
- // @description Display SC/point earn, booking class, plane type, every domestic fare at once, and AUD-converted fares on Qantas booking pages
- // @match https://book.qantas.com/*
- // @match https://www.qantas.com/*
- // @icon https://www.google.com/s2/favicons?sz=64&domain=tampermonkey.net
- // @grant unsafeWindow
- // @grant GM_xmlhttpRequest
- // @grant GM_getValue
- // @grant GM_setValue
- // @connect open.er-api.com
- // @connect api.frankfurter.dev
- // @run-at document-start
- // ==/UserScript==
- //
- // document-start gets the XMLHttpRequest patch in before the availability
- // request goes out, which the earn capture below depends on. The patch has to
- // land on the *page's* XMLHttpRequest, the one Angular calls — and this script
- // no longer runs in page scope, because the currency conversion needs
- // GM_xmlhttpRequest (book.qantas.com's CSP blocks a page-context fetch to any
- // rate API — see the currency section). Hence unsafeWindow below.
- //
- // The www.qantas.com match is for the search widget's date picker only, which
- // is a separate site and a separate app — see the "Select travel dates"
- // section. Nothing else in here runs there.
- (function () {
- 'use strict';
- // Two sites, no shared markup: book.qantas.com is the Amadeus booking
- // engine, www.qantas.com is the search widget whose date picker is the
- // only thing worth touching there.
- const onBookingSite = location.hostname === 'book.qantas.com';
- // ---- Real earn totals, captured from the availability payload ---------
- //
- // The sr-only summary in the fare grid only carries the Qantas portion of
- // the earn. On a mixed Qantas/partner itinerary the partner sector is
- // missing from it, so the grid can read well below the total the expanded
- // fare panel shows (e.g. 5,375 / 60 SC in the grid vs 6,475 / 80 SC in the
- // panel — the LAX-ANC sector on Alaska).
- //
- // The full figures are in the availability response, which arrives as JSON
- // from POSTs to /qf-booking/dyn/air/booking/<action> (preAvailability...,
- // flexPricerAvailability..., upsellUpdateAction — the action varies with
- // how the results were reached, so match on the payload shape instead):
- //
- // modelInput.availability.bounds[b].flights[itemId]
- // .listRecommendation[fareCode].earnPointsAmountList // [qantas, partner]
- // .listRecommendation[fareCode].statusCreditAmountList // [qantas, partner]
- //
- // Element 0 is exactly what the sr-only text reports; the total is the sum.
- // Verified against every fare cell on a mixed QF/Alaska search: 35 of 35
- // matched element 0, with the partner element accounting for the gap.
- //
- // The same recommendation also carries the fare price, which is what the
- // currency section below converts:
- //
- // .listRecommendation[fareCode].priceForOne
- // { totalAmount: 18092.8, currency: { code: "ZAR", nbDecimals: 2 } }
- //
- // The grid ties back to the payload through the fare radio's id, which is
- // a_<bound>_<itemId>_<fareCode>.
- const earnTotals = new Map();
- let earnVersion = 0;
- // ---- Fare families ----------------------------------------------------
- //
- // The domestic grid (see "Every fare, without the clicking" below) needs to
- // know what fares exist before it knows which of them a given flight sells,
- // so the families get indexed alongside the earn figures:
- //
- // availability.listFareFamily.sortedFareFamilies[]
- // { code: "AUAURED1JQ", name: "Red e-Deal", hierarchy: 7200,
- // rewardPlusName: null, isMarginal: false,
- // belongsToFirstDesktopColumn: true }
- //
- // belongsToFirstDesktopColumn is the cabin split the grid draws — true for
- // the "Economy Cabin" column, false for "Business Cabin". "Marginal" is the
- // payload's word for a Classic Reward fare, the one priced in points.
- const boundFareFamilies = new Map(); // boundIndex -> [meta], display order
- function indexFareFamilies(boundIndex, bound, families) {
- if (!Array.isArray(families)) return;
- const flights = bound.flights || {};
- // A family can be sold on the route but not on every departure, and a
- // column of fares no flight offers is just noise — so narrow to what
- // this bound actually quotes somewhere.
- const sold = new Set();
- Object.keys(flights).forEach(itemId => {
- Object.keys(flights[itemId]?.listRecommendation || {}).forEach(code => sold.add(code));
- });
- const metas = families.filter(f => f && sold.has(f.code)).map(f => ({
- code: f.code,
- name: f.name,
- rewardPlusName: f.rewardPlusName,
- hierarchy: f.hierarchy || 0,
- firstColumn: f.belongsToFirstDesktopColumn !== false,
- isReward: !!f.isMarginal
- }));
- // Display order inside a cabin, matching the expanded panel: revenue
- // fares by descending hierarchy, then the reward fare. sortedFareFamilies
- // is nearly that but leads with the reward fare — it comes down
- // ACEECO (8800), Red e-Deal (7200), Flex (7100) where the panel shows
- // Red e-Deal, Flex, Classic Plus.
- metas.sort((a, b) => (a.isReward - b.isReward) || (b.hierarchy - a.hierarchy));
- boundFareFamilies.set(String(boundIndex), metas);
- }
- // ---- Date strip prices ------------------------------------------------
- //
- // The "lowest fares around your travel date" strip above the grid is
- // priced in the same local currency as the fares, and comes down keyed by
- // bound in one of two places depending on which request produced it:
- //
- // modelInput.calendars[bound].tabs[] // flexCalendarOWCAction,
- // // i.e. the Prev/Next
- // // 14 days buttons
- // modelInput.availability.calendars[bound].tabs[]
- //
- // Both hold { date, price: { totalAmount, currency: { code } } } per tab,
- // and the tab index lines up with the button's id, e2e-tab-date-<bound>-<n>.
- //
- // Object.keys covers both shapes: the first is an object keyed "0", "1",
- // the second a plain array.
- const calendarPrices = new Map();
- // The currency for the search as a whole. The strip is painted on first
- // load, so it needs a currency even on the pass where only the grid's
- // payload has been seen.
- let searchCurrency = null;
- function indexCalendarPayload(model) {
- let added = 0;
- [model.calendars, model.availability?.calendars].forEach(calendars => {
- if (!calendars || typeof calendars !== 'object') return;
- Object.keys(calendars).forEach(boundIndex => {
- const tabs = calendars[boundIndex]?.tabs;
- if (!Array.isArray(tabs)) return;
- tabs.forEach((tab, tabIndex) => {
- const price = tab?.price;
- if (!(price?.totalAmount > 0)) return;
- searchCurrency = price.currency?.code || searchCurrency;
- calendarPrices.set(`${boundIndex}_${tabIndex}`, {
- price: price.totalAmount,
- currency: searchCurrency
- });
- added++;
- });
- });
- });
- // Paging the strip restates every date, same as a new search does for
- // the grid.
- if (added) rerender();
- }
- function indexPayload(text) {
- let model;
- try {
- model = JSON.parse(text)?.modelInput;
- } catch (e) {
- return; // not JSON, or not ours
- }
- if (!model) return;
- // The date strip comes down in its own responses as well as beside the
- // grid, so both get offered to both indexers.
- indexCalendarPayload(model);
- indexEarnPayload(model);
- }
- function indexEarnPayload(model) {
- // Every price in the search is quoted in this, and the date strip
- // needs it when its own payload never came past the hook. Read it
- // before the bounds check, which not every availability payload passes.
- searchCurrency = model.availability?.currency?.code || searchCurrency;
- const bounds = model.availability?.bounds;
- if (!Array.isArray(bounds)) return;
- // Every fare family the search can sell, with the names and the column
- // assignment the domestic grid below needs. Stated once for the search,
- // not per bound.
- const families = model.availability?.listFareFamily?.sortedFareFamilies;
- let added = 0;
- bounds.forEach((bound, boundIndex) => {
- const flights = bound?.flights;
- if (!flights) return;
- indexFareFamilies(boundIndex, bound, families);
- indexSegments(boundIndex, bound);
- // opAirlines is the partner operating carriers only — Qantas
- // subsidiaries (Alliance, QantasLink) are listed separately under
- // subsidiariesOpAirlines and earn in the Qantas bucket, which is
- // why they don't belong here.
- const carriersByItem = {};
- (bound.listItineraries?.itineraries || []).forEach(itinerary => {
- carriersByItem[itinerary.itemId] = (itinerary.opAirlines || [])
- .map(a => ({ code: a.code, name: a.capitalizedName }))
- .filter(a => a.code);
- });
- Object.keys(flights).forEach(itemId => {
- const recommendations = flights[itemId]?.listRecommendation || {};
- Object.keys(recommendations).forEach(fareCode => {
- const rec = recommendations[fareCode];
- const points = Array.isArray(rec?.earnPointsAmountList) ? rec.earnPointsAmountList : [];
- const credits = Array.isArray(rec?.statusCreditAmountList) ? rec.statusCreditAmountList : [];
- // A Classic Reward fare earns nothing but still has a cash
- // component worth converting, so price alone is enough to
- // make an entry. (Sold-out cells get filtered at render.)
- const price = rec?.priceForOne;
- if (!points.length && !credits.length && !(price?.totalAmount > 0)) return;
- const sum = list => list.reduce((total, n) => total + (Number(n) || 0), 0);
- earnTotals.set(`${boundIndex}_${itemId}_${fareCode}`, {
- // Per person, and matching what the cell prints: on a
- // 2-adult search the cell still read R18,093 while
- // priceForAll was 36,185.60.
- price: price?.totalAmount,
- // Stated as an ISO code per fare, so no guessing from
- // the symbol. availability.currency says the same thing
- // for the search as a whole; keep it as the fallback.
- currency: price?.currency?.code || model.availability?.currency?.code,
- qantasPoints: points[0] || 0,
- qantasCredits: credits[0] || 0,
- partnerPoints: sum(points.slice(1)),
- partnerCredits: sum(credits.slice(1)),
- points: sum(points),
- credits: sum(credits),
- partnerCarriers: carriersByItem[itemId] || [],
- // The cash half of a Classic Reward fare — what the
- // domestic grid prints as "+ $103" beside the points.
- tax: price?.tax,
- // ...and the points half. The payload never labels it
- // as points: convertedBaseFare is the fare before tax
- // expressed in points, which for a reward fare is the
- // number of points it costs.
- rewardPoints: price?.convertedBaseFare,
- // Classic Plus rather than plain Classic, which changes
- // the fare's name and nothing else.
- isRewardPlus: !!rec.isRewardPlus,
- // Only set when Qantas wants to say "2 left at this
- // price"; null the rest of the time.
- seatsLeft: rec.nbLastSeatsAvailable,
- // rbds is the booking class per segment, in segment
- // order, and differs per fare family — e.g. a Sale fare
- // priced Q/Q/G, where the G is the Alaska sector that
- // earns nothing.
- bookingClasses: Array.isArray(rec.rbds) ? rec.rbds.slice() : []
- });
- added++;
- });
- });
- });
- if (added) {
- // A new search (date change, filter) restates every fare, so let
- // the already-injected cells re-render against the new figures.
- earnVersion++;
- console.log(`[Qantas SC] captured earn totals for ${added} fares`);
- // Angular usually redraws the grid straight after, which the
- // observer picks up, but don't rely on it.
- rerender();
- }
- }
- function installEarnCapture() {
- // Any @grant other than `none` moves this script into the userscript
- // manager's sandbox, where `window` is not the page's window. Angular
- // calls the page's XMLHttpRequest, so that's the prototype to patch.
- const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
- const XHR = pageWindow.XMLHttpRequest;
- const open = XHR.prototype.open;
- const send = XHR.prototype.send;
- XHR.prototype.open = function (method, url, ...rest) {
- this._qantasScUrl = url;
- return open.call(this, method, url, ...rest);
- };
- XHR.prototype.send = function (...args) {
- if (String(this._qantasScUrl || '').includes('/dyn/air/booking/')) {
- this.addEventListener('load', () => {
- try {
- indexPayload(this.responseText);
- } catch (e) {
- console.warn('[Qantas SC] failed to index payload', e);
- }
- });
- }
- return send.apply(this, args);
- };
- }
- function lookupEarn(fareCell) {
- const id = fareCell.querySelector('input[type=radio]')?.id || '';
- const match = id.match(/^a_(\d+)_(\d+)_(.+)$/);
- return match ? earnTotals.get(`${match[1]}_${match[2]}_${match[3]}`) : undefined;
- }
- // ---- Local-currency fares, converted to AUD ---------------------------
- //
- // A search from an overseas point of sale prices in that market's currency
- // — JNB-ADL off the ZA site quotes R19,613 — and the symbol alone doesn't
- // pin it down ("R" is the rand here, but it's also the real and the
- // ringgit elsewhere). The payload doesn't leave it to the symbol: the
- // amount comes with an ISO code beside it, per fare, as
- // priceForOne.currency.code ("ZAR"), with availability.currency saying the
- // same for the search as a whole. So the conversion never has to guess.
- //
- // Rates come from a public FX API. book.qantas.com serves a strict CSP
- // whose connect-src allowlist has no room for one, so a page-context
- // fetch() to any rate host fails outright ("Failed to fetch") — which is
- // what forces GM_xmlhttpRequest here, and the sandbox/unsafeWindow dance
- // up in installEarnCapture with it.
- const HOME_CURRENCY = 'AUD';
- // Rates move slowly enough that a fare grid doesn't need them fresher than
- // this, and both sources publish daily anyway.
- const RATE_TTL_MS = 12 * 60 * 60 * 1000;
- const RATE_STORE = 'qantas-sc-fx-rates';
- // Tried in order. exchangerate-api's open endpoint covers ~160 currencies,
- // which matters because Qantas sells from points of sale (AED, ARS, CLP)
- // that the ECB — and so Frankfurter, the fallback — doesn't publish.
- const RATE_SOURCES = [
- {
- name: 'exchangerate-api',
- url: code => `https://open.er-api.com/v6/latest/${code}`,
- read: data => ({ rate: data?.rates?.[HOME_CURRENCY], date: data?.time_last_update_utc })
- },
- {
- name: 'frankfurter/ECB',
- url: code => `https://api.frankfurter.dev/v1/latest?base=${code}&symbols=${HOME_CURRENCY}`,
- read: data => ({ rate: data?.rates?.[HOME_CURRENCY], date: data?.date })
- }
- ];
- const rates = new Map(); // code -> { rate, date, source, fetchedAt }
- const rateAsked = new Set(); // one round of attempts per currency per load
- let rateVersion = 0;
- let rateStore = null;
- function readRateCache() {
- if (rateStore) return rateStore;
- try {
- rateStore = JSON.parse(GM_getValue(RATE_STORE, '{}')) || {};
- } catch (e) {
- rateStore = {};
- }
- return rateStore;
- }
- function rateFor(code) {
- if (!code || code === HOME_CURRENCY) return null;
- if (rates.has(code)) return rates.get(code);
- const cached = readRateCache()[code];
- if (cached && Date.now() - cached.fetchedAt < RATE_TTL_MS) {
- rates.set(code, cached);
- return cached;
- }
- requestRate(code);
- // A stale rate beats an empty line while the refresh is in flight, and
- // beats nothing at all if the fetch fails. Deliberately not promoted
- // into `rates`, so a successful refresh still replaces it.
- return cached || null;
- }
- function requestRate(code) {
- if (rateAsked.has(code)) return;
- rateAsked.add(code);
- const attempt = index => {
- const source = RATE_SOURCES[index];
- if (!source) {
- console.warn(`[Qantas SC] no ${code}->${HOME_CURRENCY} rate available`);
- return;
- }
- GM_xmlhttpRequest({
- method: 'GET',
- url: source.url(code),
- responseType: 'json',
- timeout: 10000,
- onload: response => {
- // responseType isn't honoured by every manager/version
- let payload = response.response;
- if (payload == null || typeof payload === 'string') {
- try {
- payload = JSON.parse(response.responseText);
- } catch (e) {
- payload = null;
- }
- }
- const { rate, date } = source.read(payload) || {};
- if (!(rate > 0)) return attempt(index + 1);
- const entry = {
- rate,
- date: String(date || '').slice(0, 30),
- source: source.name,
- fetchedAt: Date.now()
- };
- rates.set(code, entry);
- const cache = readRateCache();
- cache[code] = entry;
- try {
- GM_setValue(RATE_STORE, JSON.stringify(cache));
- } catch (e) {
- // a rate we can't cache is still a rate we can use
- }
- console.log(`[Qantas SC] 1 ${code} = ${rate} ${HOME_CURRENCY} (${source.name})`);
- // Anything rendered before the rate landed needs a
- // second pass.
- rateVersion++;
- rerender();
- },
- onerror: () => attempt(index + 1),
- ontimeout: () => attempt(index + 1)
- });
- };
- attempt(0);
- }
- const audAmount = new Intl.NumberFormat('en-AU', { maximumFractionDigits: 0 });
- const rateDigits = new Intl.NumberFormat('en-AU', { maximumSignificantDigits: 5 });
- function buildConvertedPrice(price, code) {
- const info = rateFor(code);
- if (!info) return null;
- const stale = Date.now() - info.fetchedAt >= RATE_TTL_MS;
- const line = document.createElement('div');
- line.className = 'qantas-sc-aud';
- line.style.fontSize = '0.9em';
- line.style.opacity = '0.75';
- line.textContent = `≈ A$${audAmount.format(price * info.rate)}`;
- line.title = `${audAmount.format(price)} ${code} at 1 ${code} = ` +
- `${rateDigits.format(info.rate)} ${HOME_CURRENCY}` +
- `\n${info.source}${info.date ? `, ${info.date}` : ''}${stale ? ' (stale)' : ''}`;
- return line;
- }
- // The money element in a date tab is .amount.cash on a cash search, and
- // .reward-tax ("+ R4,991") on a Classic Reward one — where the .amount
- // beside it carries the class "points" and is a points figure, not money.
- // Anything without one of these is a "No flights" date.
- const CALENDAR_PRICE = 'span.amount.cash, span.reward-tax';
- // "R18,093", "+ R4,991". The symbol varies by market and the grouping
- // doesn't on the English-language sites, which is all this has to survive:
- // it's only ever a fallback for a strip whose payload never came past the
- // XHR hook, and the figure is the payload's own rounded to whole units.
- function parseDisplayedAmount(text) {
- const value = Number(String(text || '').replace(/[^\d.,]/g, '').replace(/,/g, ''));
- return value > 0 ? value : 0;
- }
- function lookupCalendarPrice(tab) {
- const shown = parseDisplayedAmount(tab.querySelector(CALENDAR_PRICE)?.textContent);
- // The id is positional, so an indexed price is only good while the
- // strip still shows the page it came from — paging repaints the
- // buttons under the same ids, and not every repaint comes with a
- // payload we saw. The displayed figure is the payload's own rounded to
- // whole units, which makes it the check as well as the fallback.
- const match = (tab.id || '').match(/^e2e-tab-date-(\d+)-(\d+)$/);
- const indexed = match && calendarPrices.get(`${match[1]}_${match[2]}`);
- if (indexed && (!shown || Math.round(indexed.price) === shown)) return indexed;
- return shown && searchCurrency ? { price: shown, currency: searchCurrency } : null;
- }
- function insertPricesIntoCalendar() {
- document.querySelectorAll('button.cal-tab-body').forEach(tab => {
- const existing = tab.querySelector('.qantas-sc-aud');
- const priceEl = tab.querySelector(CALENDAR_PRICE);
- const entry = priceEl && lookupCalendarPrice(tab);
- const line = entry && entry.currency !== HOME_CURRENCY
- ? buildConvertedPrice(entry.price, entry.currency)
- : null;
- if (!line) {
- // Paged to a date with no flights, or a rate we can't get
- if (existing) existing.remove();
- return;
- }
- // Paging the strip repaints the price in place and leaves our line
- // alone, holding the previous date's figure — and it doesn't
- // always come with a payload to bump a version counter on.
- // Comparing the text is cheaper than working out whether a
- // repaint happened.
- if (existing) {
- if (existing.textContent === line.textContent) return;
- existing.remove();
- }
- // The strip zeroes the font size on the price wrapper (the usual
- // trick for killing whitespace between inline-blocks) and sets it
- // again per element, so an em-relative size here computes to 0 and
- // the line renders invisibly. Absolute units, and a line-height to
- // match — the date above it is 12px, the price 18px.
- line.style.fontSize = '11px';
- line.style.lineHeight = '1.4';
- line.style.textAlign = 'center';
- line.style.whiteSpace = 'nowrap';
- (priceEl.closest('.price-group') || tab).appendChild(line);
- });
- }
- function rerender() {
- if (document.body) requestAnimationFrame(render);
- }
- // Revert to the 'better' booking mode
- function removeddT26() {
- const body = document.body;
- body.classList.remove('ddT26Cash', 'ddT26');
- console.log("Removing..");
- }
- // Each entry in the sr-only summary is either a single value or split into
- // "<x> for Qantas operated flights and <y> for Partners operated flights".
- // Points and Status Credits get split independently of the other entries,
- // so the split form has to be tested for per label rather than by looking
- // for "Partners operated flights" anywhere in the string: an itinerary can
- // have one earn rate but partner-specific seating or upgrade rules.
- //
- // Values are a number, "None" (sold out, Classic Reward) or an en dash
- // (partner legs that earn nothing).
- const EARN_VALUE = '[\\d,]+|None|[\\u2013\\u2014-]';
- function parseEarned(text, label) {
- const split = text.match(new RegExp(
- `${label} (${EARN_VALUE}) for Qantas operated flights and (${EARN_VALUE}) for Partners operated flights`));
- if (split) return { qantas: split[1], partner: split[2] };
- // The lookahead needs the comma that ends the entry, and needs it not
- // to be a thousands separator: without it "5,300 for Qantas operated
- // flights and ..." backtracks down to a match of just "5".
- const single = text.match(new RegExp(`${label} (${EARN_VALUE})(?=,(?!\\d))`));
- return single ? { qantas: single[1], partner: single[1] } : null;
- }
- const earnsAnything = value => /\d/.test(value || '');
- const earnLabel = value => (earnsAnything(value) ? value : '–');
- const group = n => n.toLocaleString('en-AU');
- // Qantas marks an unbookable fare with the "inactive" class on the cell
- // (the visible text is a localised "No seats", so the class is the safer
- // signal).
- const isSoldOut = fareCell => fareCell.classList.contains('inactive');
- // Booking classes come per segment, so show them in segment order. Worth
- // surfacing because the class is what drives the earn — two otherwise
- // identical itineraries can differ only in, say, a G vs Q partner sector.
- function buildBookingClasses(classes) {
- const line = document.createElement('div');
- line.className = 'qantas-sc-rbd';
- line.style.fontSize = '0.8em';
- line.style.opacity = '0.75';
- line.style.letterSpacing = '0.05em';
- line.textContent = classes.join(' · ');
- line.title = `Booking class per segment: ${classes.join(', ')}`;
- return line;
- }
- // Qantas and partner earn on separate lines above the total, so a Double
- // Status Credits promo (Qantas operated flights only) can be read off
- // directly. The icon codes come from the same sprite the itinerary rows
- // use — img-icon-<carrier>-14, with a default tile for anything unmapped.
- function earnRow(spriteSrc, iconCode, label, text, isTotal) {
- const row = document.createElement('div');
- row.style.display = 'flex';
- row.style.alignItems = 'center';
- row.style.gap = '0.25em';
- row.style.whiteSpace = 'nowrap';
- if (isTotal) {
- row.style.borderTop = '1px solid rgba(0, 0, 0, 0.25)';
- row.style.marginTop = '0.15em';
- row.style.paddingTop = '0.15em';
- }
- if (iconCode) {
- const icon = document.createElement('img');
- icon.className = `img-icon-${iconCode.toLowerCase()}-14 mr-1`;
- icon.width = 14;
- icon.height = 14;
- icon.alt = label;
- icon.title = label;
- if (spriteSrc) icon.src = spriteSrc;
- row.appendChild(icon);
- } else {
- // Keeps the numbers on the total line aligned with the ones above:
- // same 14px as the sprite, and the same mr-1 the icons carry.
- const spacer = document.createElement('span');
- spacer.className = 'mr-1';
- spacer.style.cssText = 'display:inline-block;width:14px;flex:none';
- spacer.setAttribute('aria-hidden', 'true');
- row.appendChild(spacer);
- }
- const value = document.createElement('span');
- value.textContent = text;
- row.appendChild(value);
- return row;
- }
- function buildEarnBreakdown(totals, spriteSrc) {
- const box = document.createElement('div');
- box.style.display = 'inline-flex';
- box.style.flexDirection = 'column';
- box.style.textAlign = 'left';
- const carriers = totals.partnerCarriers || [];
- // One partner carrier gets its own tail; a mix (e.g. HA+AS) has no
- // single honest icon, so fall back to the generic one.
- const partnerIcon = carriers.length === 1 ? carriers[0].code : 'default';
- const partnerLabel = carriers.length
- ? carriers.map(c => c.name || c.code).join(' / ')
- : 'Partner operated';
- // Qantas' own wording uses an en dash for a sector that earns nothing
- const partnerText = (totals.partnerPoints || totals.partnerCredits)
- ? `${group(totals.partnerPoints)} / ${totals.partnerCredits} SC`
- : '– / – SC';
- box.appendChild(earnRow(spriteSrc, 'qf', 'Qantas operated',
- `${group(totals.qantasPoints)} / ${totals.qantasCredits} SC`, false));
- box.appendChild(earnRow(spriteSrc, partnerIcon, partnerLabel, partnerText, false));
- box.appendChild(earnRow(spriteSrc, null, 'Total',
- `${group(totals.points)} / ${totals.credits} SC`, true));
- return box;
- }
- function insertPointsIntoFares() {
- document.querySelectorAll('div.fare-cell').forEach(fareCell => {
- const srSpan = fareCell.querySelector(':scope > span.sr-only');
- const fareValue = fareCell.querySelector('span.fare-value');
- if (!srSpan || !fareValue) return;
- // Avoid injecting twice, but do re-render against a fresh payload
- // — or against a rate that arrived after the first pass.
- const renderKey = `${earnVersion}.${rateVersion}`;
- const existing = fareValue.querySelector('.qantas-extra-info');
- if (existing) {
- if (existing.dataset.renderKey === renderKey) return;
- existing.remove();
- }
- // "No seats" cells are still priced in the payload, so the earn
- // lookup happily returns figures for a fare you can't book. The
- // sr-only summary used to say "earned None" for these, which is why
- // nothing showed before the payload became the source.
- if (isSoldOut(fareCell)) return;
- const text = srSpan.textContent;
- //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,
- const info = document.createElement('div');
- info.className = 'qantas-extra-info';
- info.dataset.renderKey = renderKey;
- info.style.fontSize = '1em';
- info.style.color = '#000';
- info.style.marginTop = '0.25em';
- info.style.textAlign = 'center';
- const totals = lookupEarn(fareCell);
- // Straight under the local-currency price, so the number the cell
- // leads with means something. Nothing to add on an AUD point of
- // sale, which is the common case.
- if (totals && totals.price && totals.currency !== HOME_CURRENCY) {
- const converted = buildConvertedPrice(totals.price, totals.currency);
- if (converted) info.appendChild(converted);
- }
- if (totals && (totals.points || totals.credits)) {
- // Split on whether a partner sector exists, not on whether it
- // earns. A partner sector earning nothing (e.g. Alaska booking
- // class G) is worth seeing: it looks identical to an all-Qantas
- // itinerary otherwise, but sits beside near-identical options
- // in an earning class.
- if (totals.partnerCarriers.length) {
- // Sprite URL is build-versioned, so borrow it from the page
- const spriteSrc = document.querySelector('img[media-src]')?.src;
- info.appendChild(buildEarnBreakdown(totals, spriteSrc));
- } else {
- // Genuinely all Qantas — one line says it all
- info.insertAdjacentHTML('beforeend',
- `${group(totals.points)} / ${group(totals.credits)} SC<br>`);
- }
- if (totals.bookingClasses.length) {
- info.appendChild(buildBookingClasses(totals.bookingClasses));
- }
- } else {
- // No payload for this cell — fall back to the sr-only summary,
- // which is correct except on mixed Qantas/partner itineraries.
- const points = parseEarned(text, 'Qantas Points earned');
- const credits = parseEarned(text, 'Status Credits earned');
- const anyEarn = points && credits &&
- [points.qantas, points.partner, credits.qantas, credits.partner].some(earnsAnything);
- if (anyEarn) {
- if (points.partner === points.qantas && credits.partner === credits.qantas) {
- info.insertAdjacentHTML('beforeend',
- `${earnLabel(points.qantas)} / ${earnLabel(credits.qantas)} SC<br>`);
- } else {
- info.insertAdjacentHTML('beforeend', `
- Qantas: ${earnLabel(points.qantas)} / ${earnLabel(credits.qantas)} SC<br>
- Partner: ${earnLabel(points.partner)} / ${earnLabel(credits.partner)} SC<br>
- `);
- }
- }
- }
- // Sold-out and Classic Reward cells earn nothing at all; don't
- // leave an empty div behind.
- if (!info.innerHTML) return;
- fareValue.appendChild(info);
- });
- }
- // ---- A wider page, on the domestic grid ---------------------------------
- //
- // The grid is laid out inside section.container.page-container, which
- // Qantas caps at 972px. A quarter of that goes on the flight itself and the
- // rest is split between however many fare columns there are, which is tight
- // for the domestic tiles below — five of them on a two-cabin search, each
- // carrying a price, an earn and a booking class. Nothing in the grid is
- // fixed-width, so the room goes straight into the columns.
- //
- // Capped against the viewport rather than set flat, so a laptop gets the
- // screen it has rather than a horizontal scrollbar.
- //
- // Domestic only. The international grid draws just the three to five
- // columns of the one cabin it was asked for, and stretching those over
- // 1800px leaves each fare adrift in its own empty half-screen — there's
- // nothing to spend the width on. grouped-avail-flight-row is the domestic
- // results grid, which makes it both the signal and the reason; it appears a
- // beat after the page does, and the guard above means this just tries again
- // on the next pass until it's there.
- const WIDE_PAGE_MAX = 1800;
- function widenPageContainer() {
- if (document.getElementById('qantas-sc-width-style')) return;
- if (!document.querySelector('grouped-avail-flight-row')) return;
- const style = document.createElement('style');
- style.id = 'qantas-sc-width-style';
- // The date strip doesn't follow the page on its own. It's a fixed
- // viewport onto a longer strip: li.cal-tab-list is pinned to 872px —
- // 7.2 of the 121.111px tabs, both hard-coded in Qantas' sheet under the
- // same min-width: 980px the overrides below match — and the ul inside
- // it clips with overflow: hidden. Widening the page moves the Next
- // arrow out to the edge and leaves those seven days marooned in the
- // middle of it, which is worse than not widening at all.
- //
- // The tabs keep their own width, so the room buys days rather than
- // padding: eleven of them at 1800px instead of seven, out of the
- // fifteen the strip already holds.
- style.textContent = `
- section.container.page-container {
- max-width: min(${WIDE_PAGE_MAX}px, calc(100vw - 52px)) !important;
- }
- @media (min-width: 980px) {
- .calendar-container.calendar-container--inline
- .calendar ul.cal-body > li.cal-tab-list { width: auto !important; }
- .calendar-container.calendar-container--inline
- .cal-tab-list > ul { width: 100% !important; }
- }
- `;
- document.head.appendChild(style);
- }
- // ---- Domestic grid: every fare, without the clicking -------------------
- //
- // A domestic search draws a different grid to an international one — the
- // grouped-avail-* components, with no div.fare-cell in the results at all —
- // and it shows far less. Each flight row gets one cell per cabin, headed
- // "Economy Cabin" and "Business Cabin" — or "Premium Economy Cabin" and
- // "Business Cabin" on a search made in a premium cabin, which is why the
- // column split below is read from the payload rather than assumed — and
- // each cell carries only the cheapest fare in that cabin ("From $495").
- // The fare families behind it —
- // Red e-Deal, Flex, Classic Plus, Business — appear only once you click the
- // cell, which expands a panel below the row; one cabin at a time, one row at
- // a time. Comparing Flex across six departures is twelve clicks and a lot of
- // scrolling, and the panel's carousel can page fares out of sight when a
- // cabin sells more than a few.
- //
- // None of it is a fetch away: every fare, its price, its earn and its
- // booking class are already in the availability payload the earn capture at
- // the top indexes. So the cabin cell gets rebuilt as a strip of one tile per
- // fare family, and the whole grid reads at a glance.
- //
- // The row's ids carry the payload coordinates:
- //
- // div#call-to-action_bnd_<bound>_flt_<itemId>
- // div.fare-columns--item // first cabin
- // button#left_desktop_group_bnd_<bound>_flt_<itemId>
- // div.fare-columns--item // second cabin
- // button#right_desktop_group_bnd_<bound>_flt_<itemId>
- //
- // where <itemId> is the payload's own flights key rather than a row
- // position — a 30-row grid reads flt_1, flt_3, flt_17, flt_5 — which makes
- // the id the whole lookup. The fare panel's own Select buttons are keyed the
- // same way plus the fare code, which is how a tile finds its way back to
- // Qantas' cell:
- //
- // button#select_btn_bnd_<bound>_flt_<itemId>_reco_<fareCode>
- //
- // Desktop only, by those ids. The mobile layout is a different set of
- // elements and keeps Qantas' own behaviour.
- // Qantas' cell is left in place, just hidden, so anything this doesn't
- // handle is one style rule away from being visible again.
- const HIDE_NATIVE_CABIN_CELL = true;
- // The cabin is already spelled out in the column heading above the grid, so
- // the tiles drop it: "Economy Classic Plus" reads as "Classic Plus". A name
- // that is nothing but a cabin ("Business") keeps it.
- const CABIN_PREFIX = /^(Premium Economy|Economy|Business|First)\s+/i;
- // The "Classic" goes too, leaving a reward tile labelled just PLUS or
- // REWARD. Both are already flagged as rewards by the rosette and by Qantas'
- // red/blue, so the word carries nothing the tile doesn't already say — and
- // the room matters: a three-fare cabin leaves a tile 98px wide, of which
- // "CLASSIC REWARD" and an icon want every one, and a four-fare cabin would
- // leave about 75. The full name is on the tile's tooltip.
- const CLASSIC_PREFIX = /^Classic\s+/i;
- // What Qantas calls the fare, in full — the tile's tooltip, where there's
- // room for it.
- function fareName(meta, entry) {
- return (entry && entry.isRewardPlus && meta.rewardPlusName) || meta.name || meta.code;
- }
- const moneyFormats = new Map();
- function formatMoney(amount, code) {
- if (!(amount >= 0)) return '';
- if (!code) return audAmount.format(Math.round(amount));
- let format = moneyFormats.get(code);
- if (!format) {
- try {
- format = new Intl.NumberFormat('en-AU', {
- style: 'currency', currency: code, maximumFractionDigits: 0
- });
- } catch (e) {
- // An ISO code Intl doesn't know. Better a bare number than
- // nothing, and the converted line underneath still says AUD.
- format = audAmount;
- }
- moneyFormats.set(code, format);
- }
- // Qantas rounds to whole units in the grid and so does this, or a tile
- // reads $494.56 against a "From $495" everywhere else on the page.
- return format.format(Math.round(amount));
- }
- function fareLabel(meta, entry) {
- const full = fareName(meta, entry);
- return full.replace(CABIN_PREFIX, '').replace(CLASSIC_PREFIX, '') || full;
- }
- function injectFareGridStyles() {
- if (document.getElementById('qantas-sc-fare-grid-style')) return;
- const style = document.createElement('style');
- style.id = 'qantas-sc-fare-grid-style';
- // The strip has to fill the cabin cell for the tiles to line up under
- // the "Economy Cabin" / "Business Cabin" headings, and the cell is laid
- // out for a single button — hence the width/height and the flex on the
- // cell itself.
- style.textContent = `
- .fare-columns--item.qantas-sc-column {
- display: flex; align-items: stretch;
- }
- .qantas-sc-fares {
- display: flex; gap: 4px; width: 100%;
- padding: 6px 8px; box-sizing: border-box;
- }
- .qantas-sc-fare {
- flex: 1 1 0; min-width: 0;
- display: flex; flex-direction: column;
- /* Top-aligned, not centred: every tile leads with the fare name
- and then the price, so aligning to the top lines those rows up
- across the strip. Centring lets a tile with a "2 left" flag
- push its price out of step with the one beside it. */
- align-items: center; justify-content: flex-start; gap: 1px;
- padding: 6px 2px; margin: 0;
- /* No border of its own — the tiles already sit inside Qantas'
- own bordered row, and a box around each one reads as a second
- frame. Transparent rather than none so the hover state below
- doesn't shift the layout by a pixel. */
- border: 1px solid transparent; border-radius: 6px;
- background: #fff; font: inherit; color: #323232;
- text-align: center; cursor: pointer;
- }
- .qantas-sc-fare:hover, .qantas-sc-fare:focus-visible {
- border-color: #e40000; outline: none;
- }
- .qantas-sc-fare[disabled] {
- cursor: default; opacity: 0.4;
- border-style: dashed; border-color: #d7d7d7;
- }
- .qantas-sc-fare-name {
- font-size: 12px; line-height: 1.2; color: #5a5a5a;
- text-transform: uppercase; letter-spacing: 0.02em;
- }
- .qantas-sc-fare-price { font-size: 18px; line-height: 1.25; font-weight: 600; }
- /* The rosette is sized for body copy; the name line is 12px. */
- .qantas-sc-fare-name .icon-classic-rewards-small {
- font-size: 14px; line-height: 1; margin-right: 2px; vertical-align: -1px;
- }
- .qantas-sc-fare-sub { font-size: 12px; line-height: 1.2; opacity: 0.8; }
- .qantas-sc-fare-earn { font-size: 12px; line-height: 1.3; }
- .qantas-sc-fare-meta {
- font-size: 11px; line-height: 1.3; opacity: 0.6; letter-spacing: 0.04em;
- }
- .qantas-sc-fare-seats { font-size: 11px; line-height: 1.3; color: #c00000; }
- .qantas-sc-fare .qantas-sc-aud { font-size: 11px; line-height: 1.2; }
- ${HIDE_NATIVE_CABIN_CELL ? '.qantas-sc-column > button.cta-btn { display: none !important; }' : ''}
- `;
- document.head.appendChild(style);
- }
- // Classic Plus and Classic Reward are two different ways to pay for the
- // same seat and the tiles have to tell them apart at a glance. Qantas does
- // it by colour — blue for Classic Plus, red for Classic — and the rules
- // behind that are plain one-class utilities on its own stylesheet,
- //
- // .blue-reward { color: #29547c }
- // .classic-reward { color: #e40000 }
- //
- // unscoped by Angular, so borrowing the class beats pinning the hex here:
- // the tiles follow any repaint of Qantas' palette. Same for the rosette,
- // which is a glyph in the qf-icons font the page already loads.
- //
- // Note the two names don't line up with the two classes: isRewardPlus is
- // what says which, not the colour's name.
- function rewardVariant(entry) {
- if (!entry) return null; // no fare, so nothing to be plus or not
- return entry.isRewardPlus ? 'blue-reward' : 'classic-reward';
- }
- function buildFareTile(meta, entry, onOpen) {
- const tile = document.createElement('button');
- tile.type = 'button';
- tile.className = 'qantas-sc-fare';
- const variant = meta.isReward ? rewardVariant(entry) : null;
- const name = document.createElement('span');
- name.className = 'qantas-sc-fare-name';
- if (meta.isReward) {
- const rosette = document.createElement('i');
- rosette.className = `icon icon-classic-rewards-small${variant ? ` ${variant}` : ''}`;
- rosette.setAttribute('aria-hidden', 'true');
- name.appendChild(rosette);
- }
- // The colour goes on a span of its own rather than on .qantas-sc-fare-name,
- // which sets a colour of its own — two one-class rules tie on specificity
- // and this sheet loads last, so it would win and the tile would come out
- // grey.
- const nameText = document.createElement('span');
- if (variant) nameText.className = variant;
- nameText.textContent = fareLabel(meta, entry);
- name.appendChild(nameText);
- tile.appendChild(name);
- // A family the route sells but this departure doesn't. Keeping the tile
- // as a placeholder is what holds the columns in line down the grid,
- // which is the whole point of showing them side by side.
- if (!entry) {
- tile.disabled = true;
- const none = document.createElement('span');
- none.className = 'qantas-sc-fare-price';
- none.textContent = '–';
- tile.appendChild(none);
- return tile;
- }
- const price = document.createElement('span');
- price.className = `qantas-sc-fare-price${variant ? ` ${variant}` : ''}`;
- if (meta.isReward && entry.rewardPoints > 0) {
- // Priced in points plus a cash component, the way Qantas' own cell
- // puts it: "95,700 PTS + $103".
- price.textContent = `${group(entry.rewardPoints)} pts`;
- tile.appendChild(price);
- if (entry.tax > 0) {
- const plus = document.createElement('span');
- plus.className = 'qantas-sc-fare-sub';
- plus.textContent = `+ ${formatMoney(entry.tax, entry.currency)}`;
- tile.appendChild(plus);
- const converted = entry.currency !== HOME_CURRENCY
- ? buildConvertedPrice(entry.tax, entry.currency) : null;
- if (converted) tile.appendChild(converted);
- }
- } else {
- price.textContent = formatMoney(entry.price, entry.currency);
- tile.appendChild(price);
- // A domestic search off an overseas point of sale prices in that
- // market's currency, same as an international one.
- const converted = entry.currency !== HOME_CURRENCY
- ? buildConvertedPrice(entry.price, entry.currency) : null;
- if (converted) tile.appendChild(converted);
- }
- // A Classic Reward fare earns nothing, so there's no line to draw.
- if (entry.points || entry.credits) {
- const earn = document.createElement('span');
- earn.className = 'qantas-sc-fare-earn';
- earn.textContent = `${group(entry.points)} / ${group(entry.credits)} SC`;
- earn.title = 'Qantas Points / Status Credits earned';
- tile.appendChild(earn);
- }
- if (entry.bookingClasses.length) {
- const classes = document.createElement('span');
- classes.className = 'qantas-sc-fare-meta';
- classes.textContent = entry.bookingClasses.join('·');
- classes.title = `Booking class per segment: ${entry.bookingClasses.join(', ')}`;
- tile.appendChild(classes);
- }
- if (entry.seatsLeft > 0) {
- const seats = document.createElement('span');
- seats.className = 'qantas-sc-fare-seats';
- seats.textContent = `${entry.seatsLeft} left`;
- seats.title = `${entry.seatsLeft} left at this price`;
- tile.appendChild(seats);
- }
- const label = `${fareName(meta, entry)} — open this fare`;
- tile.title = label;
- tile.setAttribute('aria-label', label);
- tile.addEventListener('click', onOpen);
- return tile;
- }
- // A tile opens Qantas' own panel at the fare it names rather than selecting
- // it — one click to the fare conditions, bags and change fees, and no way
- // for a stray click in here to put a fare in the cart.
- function openNativeFare(item, native, boundIndex, itemId, fareCode) {
- // The cabin cell toggles its panel, so clicking one that's already open
- // would shut it. Qantas marks the open column on the cell.
- if (!item.classList.contains('column-selected')) native.click();
- // The panel animates in, so there's nothing to scroll to yet.
- setTimeout(() => {
- const select = document.getElementById(
- `select_btn_bnd_${boundIndex}_flt_${itemId}_reco_${fareCode}`);
- const cell = (select && select.closest('.fare-cell')) || select;
- if (cell) cell.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'smooth' });
- }, 450);
- }
- function insertFaresIntoGroupedRows() {
- const rows = document.querySelectorAll('div[id^="call-to-action_bnd_"]');
- if (!rows.length) return; // international layout, or results not drawn yet
- injectFareGridStyles();
- // Same two counters the international grid re-renders against: a new
- // search restates every fare, and a rate can land after the first pass.
- const renderKey = `${earnVersion}.${rateVersion}`;
- rows.forEach(cta => {
- const coords = cta.id.match(/^call-to-action_bnd_(\d+)_flt_(\d+)$/);
- if (!coords) return;
- const [, boundIndex, itemId] = coords;
- const families = boundFareFamilies.get(boundIndex);
- if (!families || !families.length) return;
- cta.querySelectorAll('.fare-columns--item').forEach(item => {
- // Qantas' own "No seats" cell — no fares behind it, and nothing
- // to expand if a tile were clicked.
- if (item.classList.contains('unavailable')) return;
- const native = item.querySelector('button.cta-btn');
- const side = native && native.id.match(/^(left|right)_desktop_group_/);
- if (!side) return;
- const columnFamilies = families.filter(f => f.firstColumn === (side[1] === 'left'));
- if (!columnFamilies.length) return;
- const existing = item.querySelector('.qantas-sc-fares');
- if (existing) {
- if (existing.dataset.renderKey === renderKey) return;
- existing.remove();
- }
- const strip = document.createElement('div');
- strip.className = 'qantas-sc-fares';
- strip.dataset.renderKey = renderKey;
- let priced = 0;
- columnFamilies.forEach(meta => {
- const entry = earnTotals.get(`${boundIndex}_${itemId}_${meta.code}`);
- if (entry) priced++;
- strip.appendChild(buildFareTile(meta, entry,
- () => openNativeFare(item, native, boundIndex, itemId, meta.code)));
- });
- // Nothing indexed for this flight. Qantas' cell still has a
- // price in it, so leave it alone rather than cover it with a
- // row of dashes.
- if (!priced) return;
- item.appendChild(strip);
- item.classList.add('qantas-sc-column');
- });
- });
- }
- // ---- Always-visible in-flight amenities -------------------------------
- //
- // Qantas only renders <flight-amenities> inside the "flight details" modal,
- // so the amenity list is missing from the results grid until you click a
- // price. The data it renders from is already client side though: the app
- // caches it in localStorage as
- //
- // flightAmenityMapping ORIG-DEST-CARRIER-NUMBER-YYYYMMDD-CABIN -> [amenityKey]
- // flightAmenityDictionary amenityKey -> { type, displayText, icon }
- //
- // both shaped as [{ tab_id, expirationDate, data: [[key, value], ...] }].
- // So we can rebuild the component ourselves and drop one into every
- // segment of every itinerary, no clicking required.
- // Icons only, on one line, with the wording on hover. Set to false for the
- // full labelled list the modal shows.
- const COMPACT_AMENITIES = true;
- // Qantas caps the modal at six and drops anything without a real icon.
- const MAX_AMENITIES = 6;
- const DEFAULT_ICON = 'amenity-ic-default';
- let amenityData = null;
- function loadAmenityData() {
- const tabId = new URLSearchParams(location.search).get('TAB_ID');
- // Prefer entries written by this booking tab; other tabs' entries are
- // stale searches that can collide on flight number.
- const entriesFor = key => {
- let parsed;
- try {
- parsed = JSON.parse(localStorage.getItem(key)) || [];
- } catch (e) {
- return [];
- }
- const now = Date.now();
- const live = parsed.filter(e => !e.expirationDate || e.expirationDate > now);
- const mine = live.filter(e => e.tab_id === tabId);
- return (mine.length ? mine : live).flatMap(e => e.data || []);
- };
- const dict = new Map(entriesFor('flightAmenityDictionary'));
- const map = new Map(entriesFor('flightAmenityMapping'));
- return dict.size && map.size ? { dict, map } : null;
- }
- // Angular scopes the component's CSS with a per-build attribute, e.g.
- // .flight-amenity-text[_ngcontent-ng-c3048682860]. Dig it out of the
- // stylesheets so our markup picks up the real styling.
- let styleToken;
- function amenityStyleToken() {
- if (styleToken !== undefined) return styleToken;
- styleToken = null;
- for (const sheet of document.styleSheets) {
- let rules;
- try {
- rules = sheet.cssRules;
- } catch (e) {
- continue; // cross-origin sheet
- }
- for (const rule of rules) {
- const match = rule.selectorText &&
- rule.selectorText.match(/\.flight-amenity-text\[(_ngcontent-[a-z0-9-]+)\]/);
- if (match) {
- styleToken = match[1];
- return styleToken;
- }
- }
- }
- return styleToken;
- }
- function injectAmenityStyles() {
- if (document.getElementById('qantas-sc-amenity-style')) return;
- const style = document.createElement('style');
- style.id = 'qantas-sc-amenity-style';
- // Three things to undo from Qantas' own styling: the amenity rows use
- // Bootstrap's .container class (full width); .flight-amenity-icon
- // carries a margin-top of -4px that collapses the line to 12px against
- // a 16px sprite; and the sprite classes pin the box to 16x16 with
- // min/max-width/height.
- //
- // That last one crops the aircraft tiles. Measured off the sheet, every
- // img-amenity-ic-plane-* tile is 18px tall (the others are 14px or
- // less), so a 16px box cuts the bottom 2px — exactly the strip holding
- // the aircraft type text. Qantas sizes these at 18px via
- // .flight-amenity-icon.mr-1.ng-star-inserted, but that needs their
- // Angular marker class, so set it here instead. min/max have to be
- // overridden too, or they win over width/height.
- style.textContent = `
- flight-amenities.qantas-sc-amenities .flight-amenity-icon {
- width: 18px; height: 18px;
- min-width: 18px; max-width: 18px;
- min-height: 18px; max-height: 18px;
- }
- flight-amenities.qantas-sc-amenities { display: block; font-size: 14px; line-height: 1.35; margin-top: 0.5em; }
- flight-amenities.qantas-sc-amenities .title { font-weight: 600; margin: 0 0 0.15em; }
- flight-amenities.qantas-sc-amenities .container { display: flex; align-items: center; }
- flight-amenities.qantas-sc-amenities .flight-amenity-icon { flex: none; }
- flight-amenities.qantas-sc-amenities.compact { display: flex; align-items: center; gap: 0.4em; line-height: 1; }
- flight-amenities.qantas-sc-amenities.compact .container { flex: 0 0 auto; width: auto; margin: 0; padding: 0; }
- flight-amenities.qantas-sc-amenities.compact .container .flight-amenity-icon { margin: 0; cursor: help; }
- flight-amenities.qantas-sc-amenities.compact .title,
- flight-amenities.qantas-sc-amenities.compact .flight-amenity-text { display: none; }
- /* The domestic row's own grid already reserves a full-width track
- for this, named and sitting at 0px for want of anything in it. */
- .info-details .qantas-sc-amenity-strips {
- grid-area: flight-amenities;
- display: flex; flex-direction: column; gap: 2px;
- margin-top: 0.25em; min-width: 0;
- }
- .info-details .qantas-sc-amenity-strips flight-amenities.qantas-sc-amenities {
- margin-top: 0;
- }
- `;
- document.head.appendChild(style);
- }
- // The grid only gives us a flight number, so match on carrier + number and
- // disambiguate with the aircraft icon the row already carries as a class
- // (e.g. "segment e2e-amenity-ic-plane-b-737-flat"), then by cabin.
- function lookupAmenities(segment, flightNumber, preferredCabin) {
- const { dict, map } = amenityData;
- const parts = flightNumber.match(/^([A-Z0-9]{2})\s*0*(\d+)$/);
- if (!parts) return null;
- const needle = `-${parts[1]}-${parts[2]}-`;
- const aircraftIcon = [...segment.classList]
- .map(c => c.replace(/^e2e-/, ''))
- .find(c => c.startsWith('amenity-ic-plane'));
- let keys = [...map.keys()].filter(k => k.includes(needle));
- if (aircraftIcon) {
- // Case-insensitive: the row's class is lower-cased but the
- // dictionary isn't always (see the A321neo note in buildAmenities).
- const sameAircraft = keys.filter(k =>
- (map.get(k) || []).some(id => dict.get(id) &&
- dict.get(id).icon.toLowerCase() === aircraftIcon.toLowerCase()));
- if (sameAircraft.length) keys = sameAircraft;
- }
- const key = keys.find(k => k.endsWith(`-${preferredCabin}`)) || keys[0];
- if (!key) return null;
- const items = (map.get(key) || [])
- .map(id => dict.get(id))
- .filter(item => item && item.icon !== DEFAULT_ICON)
- .slice(0, MAX_AMENITIES);
- return items.length ? items : null;
- }
- function buildAmenities(items, spriteSrc) {
- const token = amenityStyleToken();
- const scoped = el => {
- if (token) el.setAttribute(token, '');
- return el;
- };
- const root = document.createElement('flight-amenities');
- root.className = 'qantas-sc-amenities' + (COMPACT_AMENITIES ? ' compact' : '');
- if (token) root.setAttribute(token.replace('_ngcontent', '_nghost'), '');
- const title = scoped(document.createElement('div'));
- title.className = 'title';
- title.textContent = 'In-flight Amenities';
- root.appendChild(title);
- items.forEach(item => {
- const container = scoped(document.createElement('div'));
- container.className = 'container';
- container.title = item.displayText;
- // Qantas hides the icon from screen readers and puts the raw
- // amenity key in an sr-only span; alt text is more use to both
- // screen readers and hover, especially with the label collapsed.
- const icon = scoped(document.createElement('img'));
- // Lower-cased because the dictionary doesn't always agree with the
- // sprite sheet on case: the A321neo comes through as
- // "amenity-ic-plane-a-32Q-flat" while the only rule for it is
- // .img-amenity-ic-plane-a-32q-flat, and class matching is
- // case-sensitive — so that one tile silently rendered blank.
- icon.className = `flight-amenity-icon img-${item.icon.toLowerCase()}` +
- (COMPACT_AMENITIES ? '' : ' mr-1'); // the strip spaces with gap instead
- icon.alt = item.displayText;
- icon.title = item.displayText;
- if (spriteSrc) icon.src = spriteSrc;
- const text = scoped(document.createElement('span'));
- text.className = 'flight-amenity-text';
- text.textContent = item.displayText;
- container.append(icon, text);
- root.appendChild(container);
- });
- return root;
- }
- function preferredCabin() {
- const label = (document.querySelector('cabin-selector')?.innerText || '').toLowerCase();
- if (label.includes('premium')) return 'PREMIUM';
- if (label.includes('business')) return 'BUSINESS';
- if (label.includes('first')) return 'FIRST';
- return 'ECON';
- }
- function insertAmenitiesIntoSegments() {
- const segments = document.querySelectorAll('upsell-segment-details .segment');
- if (!segments.length) return;
- if (!amenityData) amenityData = loadAmenityData();
- if (!amenityData) return; // written a little after the results render
- injectAmenityStyles();
- // The sprite URL is build-versioned, so borrow it from the page.
- const spriteSrc = document.querySelector('img[media-src]')?.src;
- const cabin = preferredCabin();
- let missed = false;
- segments.forEach(segment => {
- // Selecting a fare makes Qantas render its own amenity strip per
- // segment — as a sibling of .segment, directly under
- // upsell-segment-details, which is why checking inside .segment
- // isn't enough to notice it. Stand down where theirs is present,
- // and pull ours back out if it turned up after we'd injected.
- //
- // "Present" has to mean "actually rendered something": on partner
- // segments (e.g. AA) Qantas emits an empty <flight-amenities> with
- // no icons at all, and treating that as theirs leaves the row
- // blank when we do have the data for it.
- const details = segment.closest('upsell-segment-details') || segment;
- const ours = details.querySelector('flight-amenities.qantas-sc-amenities');
- const nativeShown = [...details.querySelectorAll('flight-amenities:not(.qantas-sc-amenities)')]
- .some(strip => strip.querySelector('img'));
- if (nativeShown) {
- if (ours) ours.remove();
- return;
- }
- // Avoid injecting twice
- if (ours) return;
- const flightNumber = segment.querySelector('.e2e-flight-number')?.textContent?.trim();
- if (!flightNumber) return;
- const items = lookupAmenities(segment, flightNumber, cabin);
- if (!items) {
- missed = true;
- return;
- }
- segment.appendChild(buildAmenities(items, spriteSrc));
- });
- // A new search writes fresh localStorage entries under the same keys;
- // drop the cache so the next pass picks them up.
- if (missed) amenityData = null;
- }
- // ---- Amenities on the domestic grid -----------------------------------
- //
- // The domestic grid has no upsell-segment-details to hang anything off, and
- // its rows carry no aircraft icon to disambiguate a flight number by — so
- // the guesswork above doesn't transfer. It doesn't need to: the availability
- // payload states every part of the cache key outright, so a row can ask for
- // its icons by name.
- //
- // bounds[b].listItineraries.itineraries[].segments[]
- // { beginLocationCode: "SYD3", endLocationCode: "MEL1",
- // cachedAirlineCode: "QF", flightNumber: "443",
- // beginDate: 1792845600000 }
- //
- // Two traps in building the key from that. The location codes carry a
- // terminal qualifier — "SYD3", "MEL1", and "AVVD" for Avalon, so it isn't
- // always a digit — which makes the airport the first three characters and
- // nothing else. And beginDate is local wall-clock stored as a UTC instant:
- // getUTCHours() gives the departure time the grid prints (08:05), while
- // reading it in the browser's own zone shifts it (18:35 in Adelaide) and
- // takes the date with it on any morning flight. So the date has to be read
- // in UTC.
- //
- // Verified against the cache on a 27-flight SYD-ADL search: 61 of 61
- // segments matched, none missed.
- //
- // Where it goes is Qantas' own decision. .info-details is a grid whose
- // fourth row is already named flight-amenities, spanning all four columns
- // and sitting at 0px because nothing is ever put in it:
- //
- // "departure-loc departure-loc arrival-loc arrival-loc"
- // "departure-time travel-icon-container ... arrival-time"
- // "code code code segment-duration"
- // "flight-amenities flight-amenities ... flight-amenities"
- const flightSegments = new Map(); // `${bound}_${itemId}` -> [key prefix]
- // ECON first: it's the cabin most searches are in, and the aircraft and
- // wi-fi lines read the same whichever cabin's entry answers.
- const AMENITY_CABINS = ['ECON', 'PREMIUM', 'BUSINESS', 'FIRST'];
- const twoDigits = n => String(n).padStart(2, '0');
- function amenityKeyPrefix(segment) {
- const airport = code => String(code || '').slice(0, 3);
- const carrier = segment.cachedAirlineCode || segment.codeForIcon || segment.airline?.code;
- const number = Number(segment.flightNumber);
- const departs = new Date(segment.beginDate);
- if (!carrier || !(number > 0) || !Number.isFinite(departs.getTime())) return null;
- if (airport(segment.beginLocationCode).length < 3) return null;
- if (airport(segment.endLocationCode).length < 3) return null;
- const date = `${departs.getUTCFullYear()}${twoDigits(departs.getUTCMonth() + 1)}` +
- `${twoDigits(departs.getUTCDate())}`;
- return `${airport(segment.beginLocationCode)}-${airport(segment.endLocationCode)}` +
- `-${carrier}-${number}-${date}-`;
- }
- function indexSegments(boundIndex, bound) {
- (bound.listItineraries?.itineraries || []).forEach(itinerary => {
- const prefixes = (itinerary.segments || []).map(amenityKeyPrefix);
- // All or nothing: a partial itinerary would draw a strip for some
- // legs and silently drop the rest, which reads as "this leg has no
- // wi-fi" rather than "we couldn't tell".
- if (prefixes.length && prefixes.every(Boolean)) {
- flightSegments.set(`${boundIndex}_${itinerary.itemId}`, prefixes);
- }
- });
- }
- function lookupAmenitiesByKey(prefix, cabin) {
- const { dict, map } = amenityData;
- const order = [cabin, ...AMENITY_CABINS.filter(c => c !== cabin)];
- const ids = order.map(c => map.get(prefix + c)).find(Boolean);
- if (!ids) return null;
- const items = ids
- .map(id => dict.get(id))
- .filter(item => item && item.icon !== DEFAULT_ICON)
- .slice(0, MAX_AMENITIES);
- return items.length ? items : null;
- }
- function insertAmenitiesIntoGroupedRows() {
- const rows = document.querySelectorAll('div[id^="flight_row_bnd_"]');
- if (!rows.length) return; // international layout, or nothing drawn yet
- if (!amenityData) amenityData = loadAmenityData();
- if (!amenityData) return; // written a little after the results render
- injectAmenityStyles();
- // The sprite URL is build-versioned, so borrow it from the page.
- const spriteSrc = document.querySelector('img[media-src]')?.src;
- const cabin = preferredCabin();
- let missed = false;
- rows.forEach(row => {
- const coords = row.id.match(/^flight_row_bnd_(\d+)_flt_(\d+)$/);
- if (!coords) return;
- const prefixes = flightSegments.get(`${coords[1]}_${coords[2]}`);
- if (!prefixes) return;
- // A connection draws one .info-details per leg, each with its own
- // flight number and its own reserved amenities track, so every leg
- // gets its own strip in its own block. Putting them all in the
- // first block — which is what one querySelector gets you — stacks a
- // connection's two aircraft under its first leg.
- const hosts = [...row.querySelectorAll('.info-details')];
- // Paired by flight number rather than by position: the two lists
- // should agree, but the block says outright which leg it is.
- // Matched prefixes are consumed so a repeat still pairs in order.
- const unused = prefixes.slice();
- hosts.forEach((host, index) => {
- const flight = host.querySelector('.e2e-flight-number')?.textContent?.trim() || '';
- const parts = flight.match(/^([A-Z0-9]{2})\s*0*(\d+)$/);
- let at = parts
- ? unused.findIndex(prefix => prefix.includes(`-${parts[1]}-${Number(parts[2])}-`))
- : -1;
- if (at < 0) at = unused.indexOf(prefixes[index]);
- if (at < 0) return;
- const prefix = unused.splice(at, 1)[0];
- // Expanding a fare makes Qantas render its own strip into this
- // same .info-details, and both land in the row's one
- // flight-amenities grid track — so the two sets stack a few
- // pixels apart and read as fuzzy. Theirs wins: it's the same
- // six icons from the same dictionary (verified leg by leg on
- // SYD-CNS, aircraft tile included), so standing down loses
- // nothing. "Present" has to mean "actually rendered": partner
- // segments get an empty <flight-amenities>, and treating that
- // as theirs would blank a row we do have data for. The panel
- // can't be collapsed again, but if it ever goes, the next pass
- // puts ours back.
- const theirs = [...host.querySelectorAll('flight-amenities:not(.qantas-sc-amenities)')]
- .some(strip => strip.querySelector('img'));
- if (theirs) {
- host.querySelector('.qantas-sc-amenity-strips')?.remove();
- return;
- }
- // Changing the cabin filter changes which entry answers, so the
- // cabin is the render key here rather than the payload version.
- const existing = host.querySelector('.qantas-sc-amenity-strips');
- if (existing) {
- if (existing.dataset.cabin === cabin) return;
- existing.remove();
- }
- const items = lookupAmenitiesByKey(prefix, cabin);
- if (!items) {
- missed = true;
- return;
- }
- const box = document.createElement('div');
- box.className = 'qantas-sc-amenity-strips';
- box.dataset.cabin = cabin;
- box.appendChild(buildAmenities(items, spriteSrc));
- host.appendChild(box);
- });
- });
- // A new search writes fresh localStorage entries under the same keys;
- // drop the cache so the next pass picks them up.
- if (missed) amenityData = null;
- }
- // ---- "Select travel dates" calendar on the search widget --------------
- //
- // The date picker on qantas.com's Book a flight widget puts a lowest fare
- // under each date, in the point-of-sale currency — when the route has
- // market pricing to show, which plenty don't. That's why the prices are
- // there on some searches and not others; nothing to work around, just a
- // reason not to assume a cell has one.
- //
- // Different site, different app: no Angular, no availability payload. The
- // prices come from
- //
- // GET https://api.qantas.com/market-pricing/mpp/v1/calendar
- // -> { currency: "ZAR", isClassic: false,
- // days: [ { departureDate: "140926", // DDMMYY
- // totalPrice: 68031.8,
- // roundedOffTotalPrice: "68,032" } ] }
- //
- // and over fetch rather than XMLHttpRequest, so they need their own hook.
- // The DOM hands the join key straight over: each price sits in a
- // <div id="140926-price">, keyed by exactly the departureDate the API
- // used. The classes around it are emotion hashes that change every build,
- // so the id and the day button's data-testid are all this leans on.
- const MPP_CALENDAR = '/market-pricing/mpp/v1/calendar';
- const widgetPrices = new Map(); // DDMMYY -> { price, currency, shown }
- function indexWidgetCalendar(text) {
- let data;
- try {
- data = JSON.parse(text);
- } catch (e) {
- return;
- }
- if (!Array.isArray(data?.days)) return;
- let added = 0;
- data.days.forEach(day => {
- if (!day?.departureDate || !(day.totalPrice > 0)) return;
- widgetPrices.set(String(day.departureDate), {
- price: day.totalPrice,
- currency: data.currency,
- // What the cell prints, so the render can check it's looking
- // at the day it thinks it is
- shown: parseDisplayedAmount(day.roundedOffTotalPrice)
- });
- added++;
- });
- if (added) rerender();
- }
- function installWidgetCapture() {
- const pageWindow = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
- const fetch0 = pageWindow.fetch;
- if (typeof fetch0 !== 'function') return;
- pageWindow.fetch = function (...args) {
- const url = String(args[0]?.url || args[0] || '');
- const response = fetch0.apply(this, args);
- if (url.includes(MPP_CALENDAR)) {
- // A clone, so the app still gets to read its own body.
- response
- .then(result => result.clone().text())
- .then(indexWidgetCalendar)
- .catch(() => { /* the app deals with its own failures */ });
- }
- return response;
- };
- }
- function insertPricesIntoWidgetCalendar() {
- // The observer is watching every page on qantas.com, most of which
- // have no picker on them. Nothing to look for until the API has
- // answered, and it only answers once the picker is opened.
- if (!widgetPrices.size) return;
- document.querySelectorAll('div[id$="-price"]').forEach(priceEl => {
- const key = priceEl.id.slice(0, -'-price'.length);
- if (!/^\d{6}$/.test(key)) return;
- const day = widgetPrices.get(key);
- const cell = priceEl.closest('button');
- if (!cell) return;
- const existing = cell.querySelector('.qantas-sc-aud');
- // An empty cell is a day with no fare. A mismatch means the cell
- // has been repainted for a date the entry isn't for — the picker
- // reuses these ids across a return trip's two calendars.
- const usable = day && day.currency !== HOME_CURRENCY &&
- parseDisplayedAmount(priceEl.textContent) === day.shown;
- const line = usable ? buildConvertedPrice(day.price, day.currency) : null;
- if (!line) {
- if (existing) existing.remove();
- return;
- }
- if (existing) {
- if (existing.textContent === line.textContent) return;
- existing.remove();
- }
- // The cell is a 104px flex column — date, price, and room for one
- // more line. The price above it is 18px.
- line.style.fontSize = '11px';
- line.style.lineHeight = '1.4';
- line.style.textAlign = 'center';
- line.style.whiteSpace = 'nowrap';
- cell.appendChild(line);
- });
- }
- let pending = false;
- function render() {
- if (onBookingSite) {
- widenPageContainer();
- insertPointsIntoFares();
- insertFaresIntoGroupedRows();
- insertPricesIntoCalendar();
- insertAmenitiesIntoSegments();
- insertAmenitiesIntoGroupedRows();
- } else {
- insertPricesIntoWidgetCalendar();
- }
- }
- function refresh() {
- if (onBookingSite) removeddT26();
- render();
- }
- const observer = new MutationObserver(() => {
- if (pending) return;
- pending = true;
- requestAnimationFrame(() => {
- pending = false;
- refresh();
- });
- });
- function startWatchingTheDom() {
- observer.observe(document.body, { childList: true, subtree: true });
- refresh();
- }
- // The request hooks have to go in immediately — on the booking site the
- // availability request is already on its way. Everything else waits for a
- // body to exist.
- if (onBookingSite) {
- installEarnCapture();
- } else {
- installWidgetCapture();
- }
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', startWatchingTheDom, { once: true });
- } else {
- startWatchingTheDom();
- }
- })();
Advertisement
Add Comment
Please, Sign In to add comment