PageSourceSearch

https://www.westpac.com.au/scripts/utility/ddo-utils.js

js westpac.com.au collected 2026-09-24 08:27:09 UTC 23,716 bytes, 673 lines download raw bytes

1/**
2 * DDO Utilities
3 *
4 * Two utilities for the DDO product assessment flow:
5 *
6 * 1. DDO Filter API util — calls the DDO Product Filter API and caches the result
7 *    in module-level memory for the lifetime of the page.
8 *
9 * 2. DDO Product Assessment API util — reads the cached filter result, assembles
10 *    the product assessment params, persists to sessionStorage, then calls the
11 *    MEDB flow (currently a console.log placeholder).
12 *
13 * Usage:
14 *   - Call initDdoFilterApi() once on page load (scripts.js loadLazy)
15 *   - Call callDdoProductAssessmentApi(surveyAnswers) either:
16 *       a) on page load when consent is NOT mandatory, or
17 *       b) from the confirmation block CTA click when consent IS mandatory
18 */
19
20import { getMetadata } from '../aem.js';
21// eslint-disable-next-line import/no-unresolved
22import { transformMedbLinks } from '../medb-link-transformer.js';
23// eslint-disable-next-line import/no-cycle
24import handleDdoError from './ddo-error-utils.js';
25import { getPlaceholderValue } from './placeholder-cache.js';
26import validateSelectionConfigurationDropdowns from './selection-configuration-validation.js';
27
28// =============================================================================
29// Constants
30// =============================================================================
31
32const PRODUCT_ASSESSMENTS_SESSION_KEY = 'productAssessmentsParams';
33const FILTER_API_ORIGINATING_SYSTEM_ID = 'A019BF';
34const FILTER_API_CONSUMER_TYPE = 'Public';
35const PRODUCT_ASSESSMENT_ORIGINATING_SYSTEM_ID = 'A019BF';
36const DATA_LAYER_PAGE_NAME = 'rs2 submitted';
37const DATA_LAYER_SITE_NAME = 'ddo-product-selector';
38
39// Placeholder assessmentId — will be replaced with the real value from the
40// Product Assessment API POST response once that integration is complete.
41const ASSESSMENT_ID_PLACEHOLDER = '';
42
43// =============================================================================
44// Module-level cache — shared across all importers on the same page
45// =============================================================================
46
47/** @type {Object|null} Cached Filter API response, set by initDdoFilterApi() */
48let filterApiResult = null;
49
50// =============================================================================
51// Private helpers
52// =============================================================================
53
54/**
55 * Returns true when the current page is served from a Westpac production domain
56 * (*.westpac.com.au). handleDdoError() is only called on production — not on
57 * localhost, AEM preview (.aem.page), or Universal Editor authoring.
58 *
59 * @returns {boolean}
60 */
61function isWestpacDomain() {
62  const { hostname } = window.location;
63  return hostname === 'www.westpac.com.au' || hostname.endsWith('.westpac.com.au');
64}
65
66/**
67 * Persist product assessment params to sessionStorage.
68 *
69 * @param {Object} params
70 */
71function writeProductAssessmentParams(params) {
72  sessionStorage.setItem(PRODUCT_ASSESSMENTS_SESSION_KEY, JSON.stringify(params));
73}
74
75/**
76 * Remove persist:root from sessionStorage if it exists.
77 * Called before triggering MEDB link transformation.
78 */
79function clearPersistRoot() {
80  if (sessionStorage.getItem('persist:root') !== null) {
81    sessionStorage.removeItem('persist:root');
82  }
83}
84
85/**
86 * Build the productID array for DDO pages from the cached Filter API result.
87 *
88 * @returns {Array<Object>}
89 */
90function buildDdoProductId() {
91  const filterProducts = filterApiResult?.data?.products || [];
92  const contentId = filterApiResult?.data?.contentId || '';
93
94  return filterProducts.map((product) => {
95    const enterpriseProductId = String(product?.enterpriseProductId || '').toLowerCase();
96    const productName = String(product?.productName || '').toLowerCase();
97
98    return {
99      Product: enterpriseProductId,
100      ProductName: productName,
101      ProductCategory: contentId,
102      ProductFamily: contentId,
103      ProductSubCategory: productName,
104    };
105  });
106}
107
108/**
109 * Build toolOutcome for DDO pages from cached Filter API product names.
110 *
111 * @returns {string}
112 */
113function buildDdoToolOutcome() {
114  const filterProducts = filterApiResult?.data?.products || [];
115
116  return filterProducts
117    .map((product) => String(product?.productName || '').replaceAll(':', '').toLowerCase().trim())
118    .filter(Boolean)
119    .join('|');
120}
121
122/**
123 * Add the DDO event key once using the analytics data-layer format.
124 *
125 * @param {Object} pageDetails
126 * @param {number} timestamp
127 */
128export function addDdoEventKey(pageDetails, timestamp = Date.now()) {
129  const {
130    eventKey,
131    pageName = '',
132    pageType = '',
133    siteBrand = '',
134    siteExperience = '',
135  }
135 = pageDetails;
136  const eventTimestamp = Math.floor(timestamp / 1000);
137  const nextEventKey = `${siteBrand}:${siteExperience}_${pageType}_${pageName}_${eventTimestamp}`;
138
139  Object.assign(pageDetails, {
140    eventKey: eventKey ?? nextEventKey,
141  });
142}
143
144/**
145 * Build the x-wmi header value from window.pageDetails.
146 * Encodes: appName, appPath (URL path only), brand, pageName (last ':' segment).
147 *
148 * @param {Object} pageDetails - window.pageDetails set by analytics.js
149 * @returns {string} Base64-encoded wmi string
150 */
151function buildWmiHeader(pageDetails) {
152  const appName = pageDetails?.journeyType === 'pub' ? 'web' : pageDetails?.journeyType || 'web';
153  const appPath = pageDetails?.pageURL ? new URL(pageDetails.pageURL).pathname : '/';
154  const brand = pageDetails?.siteBrand || 'wbc';
155  const rawPageName = pageDetails?.pageName || '';
156  const pageName = rawPageName.includes(':')
157    ? rawPageName.split(':').pop()
158    : rawPageName || 'home';
159  const wmiString = `appName:${appName};appPath:${appPath};brand:${brand};pageName:${pageName}`;
160  return btoa(wmiString);
161}
162
163/**
164 * Execute the DDO Product Filter API GET request.
165 * Reads contentIdForProductSchema and productVariantCode from page metadata.
166 *
167 * @returns {Promise<Object>} Parsed Filter API response
168 */
169async function fetchFilterApi() {
170  const pageDetails = window.pageDetails || {};
171  const contentId = getMetadata('contentidforproductschema') || '';
172
173  // Read all product variant code fields from metadata and combine
174  const productVariantCode = getMetadata('productvariantcode') || '';
175  const bundleProductVariantCode = getMetadata('bundleproductvariantcode') || '';
176  const crossSellProductVariantCode = getMetadata('crosssellproductvariantcode') || '';
177
178  // Combine and deduplicate variant codes
179  const variantCodes = new Set();
180  if (productVariantCode) {
181    productVariantCode.split(',').forEach((code) => variantCodes.add(code.trim()));
182  }
183  if (bundleProductVariantCode) {
184    bundleProductVariantCode.split(',').forEach((code) => variantCodes.add(code.trim()));
185  }
186  if (crossSellProductVariantCode) {
187    crossSellProductVariantCode.split(',').forEach((code) => variantCodes.add(code.trim()));
188  }
189
190  const queryParts = [];
191  if (contentId) queryParts.push(`id=${encodeURIComponent(contentId)}`);
192  if (variantCodes.size > 0) {
193    // Encode each code individually and join with literal commas (not %2C)
194    // e.g. productVariantCode=in:LifeJoint,ChoiceJoint,eSaverJoint
195    const variantCodeString = Array.from(variantCodes).map((code) => encodeURIComponent(code)).join(',');
196    queryParts.push(`productVariantCode=in:${variantCodeString}`);
197  }
198
199  const filterApiUrl = await loadPlaceholder('Environment Variables', 'ddo-filter-api'); // eslint-disable-line no-use-before-define
200  const url = queryParts.length ? `${filterApiUrl}?${queryParts.join('&')}` : filterApiUrl;
201  const wmi = buildWmiHeader(pageDetails);
202  const messageId = crypto.randomUUID();
203
204  const response = await fetch(url, {
205    method: 'GET',
206    headers: {
207      'x-messageid': messageId,
208      'x-originatingsystemid': FILTER_API_ORIGINATING_SYSTEM_ID,
209      'x-consumertype': FILTER_API_CONSUMER_TYPE,
210      'x-wmi': wmi,
211    },
212  });
213
214  if (!response.ok) {
215    throw new Error(`DDO Filter API responded with ${response.status} ${response.statusText}`);
216  }
217
218  const data = await response.json();
219  return data;
220}
221
222// =============================================================================
223// Public exports
224// =============================================================================
225
226/**
227 * Load a single placeholder value from /site-configuration/placeholders.json.
228 * Legacy URL-encoded sheet names are normalized before using the shared placeholder cache.
229 *
230 * Supported sheets and their column shapes:
231 *   - 'Labels'                : { Name, Value }
232 *   - 'Environment+Variables' : { Variable Name, Domain }
233 *
234 * @param {string} sheet - Sheet query param (e.g. 'Labels', 'Environment+Variables')
235 * @param {string} key - Key to look up in the first column
236 * @returns {Promise<string>} Matched value, or empty string if not found
237 */
238export async function loadPlaceholder(sheet, key) {
239  const sheetName = sheet.replaceAll('+', ' ');
240  return getPlaceholderValue(sheetName, key);
241}
242
243/**
244 * Utility 1 — DDO Filter API
245 *
246 * Calls the DDO Product Filter API and caches the result in module memory.
247 * Call once on page load for DDO template pages (scripts.js loadLazy).
248 * Safe to call multiple times — subsequent calls are no-ops if already cached.
249 *
250 * @returns {Promise<void>}
251 */
252export async function initDdoFilterApi() {
253  if (filterApiResult) {
254    return true;
255  }
256  try {
257    filterApiResult = await fetchFilterApi();
258    return true;
259  } catch (err) {
260    filterApiResult = null;
261    if (isWestpacDomain()) await handleDdoError();
262    return false;
263  }
264}
265
266/**
267 * Returns the cached DDO Filter API result.
268 * Returns null if initDdoFilterApi() has not been called or failed.
269 *
270 * @returns {Object|null}
271 */
272export function getDdoFilterResult() {
273  return filterApiResult;
274}
275
276/**
277 * Triggers MEDB link transformation on the CTA button using the persisted
278 * product assessment params already written to sessionStorage.
279 * transformMedbLinks() reads sessionStorage internally, so params is used
280 * only for logging.
281 *
282 * @param {Object} params - Assembled product assessment params (for logging)
283 * @param {HTMLElement|null} ctaEl - The CTA button element to transform
284 */
285export function callMedbFlow(params, ctaEl) {
286  if (!ctaEl) {
287    return;
288  }
289  transformMedbLinks(ctaEl, params);
290}
291
292// =============================================================================
293// Private helpers — dynamic payload building
294// =============================================================================
295
296/**
297 * Determine the optedInType for a product by checking which metadata variant
298 * code list its productVariantCode belongs to.
299 *
300 * @param {string} productVariantCode - from Filter API response
301 * @param {Set<string>} bundleCodes - from bundleproductvariantcode metadata
302 * @param {Set<string>} crossSellCodes - from crosssellproductvariantcode metadata
303 * @returns {'Bundled'|'CrossSell'|'Primary'}
304 */
305function determineOptedInType(productVariantCode, bundleCodes, crossSellCodes) {
306  if (bundleCodes.has(productVariantCode)) return 'Bundled';
307  if (crossSellCodes.has(productVariantCode)) return 'CrossSell';
308  return 'Primary';
309}
310
311/**
312 * Map confirmation block survey answers to the API survey payload format.
313 * Labels are base64-encoded for API transmission.
314 *
315 * @param {Array<{id: string, label: string, isSelected: boolean}>} surveyAnswers
316 * @returns {Array|undefined}
317 */
318function buildSurveyPayload(surveyAnswers) {
319  if (!surveyAnswers || surveyAnswers.length === 0) return undefined;
320  return surveyAnswers.map((answer) => ({
321    question: { id: answer.id, label: btoa(answer.label) },
322    answers: [{ id: answer.id, label: btoa(answer.label), isSelected: answer.isSelected }],
323  }));
324}
325
326/**
327 * Build the optedInProducts array by mapping Filter API products to the
328 * Product Assessment API payload format.
329 *
330 * @param {Object} filterData - filterApiResult.data
331 * @param {Array<{id: string, label: string, isSelected: boolean}>} surveyAnswers
332 * @param {string} consentType - 'pageload' | 'linkclick'
333 * @param {Set<string>} bundleCodes
334 * @param {Set<string>} crossSellCodes
335 * @returns {Array}
336 */
337function buildOptedInProducts(filterData, surveyAnswers, consentType, bundleCodes, crossSellCodes) {
338  const {
339    contentId,
340    moduleId,
341    versionId,
342    products = [],
343  } = filterData;
344  const survey = buildSurveyPayload(surveyAnswers);
345
346  return products.map((product) => {
347    const optedInType = determineOptedInType(
348      product.productVariantCode,
349      bundleCodes,
350      crossSellCodes,
351    );
352    const isProductWithinTMD = !!product.tmdId;
353
354    const optedInProduct = {
355      productId: product.productId,
356      productName: btoa(product.productName || ''),
357      productVariantCode: product.productVariantCode,
358      enterpriseProductId: product.enterpriseProductId,
359      optedInType,
360      isEligible: true,
361      hasConsentAccepted: true,
362      consentType,
363      isProductWithinTMD,
364      productContentReference: {
365        contentId,
366        moduleId,
367        versionId,
368      },
369      originalProductId: product.productId,
370    };
371
372    if (isProductWithinTMD) {
373      optedInProduct.targetMarketDetermination = {
374        TMDNumber: product.tmdId,
375        TMDVersion: product.tmdVersion,
376        TMDEffectiveDate: product.tmdDate,
377      };
378    }
379
380    if (survey) {
381      optedInProduct.survey = survey;
382    }
383
384    return optedInProduct;
385  });
386}
387
388/**
389 * Build the productAssessmentsParams object from the cached Filter API result
390 * and page metadata. All fields are derived dynamically except assessmentId,
391 * which is kept as a constant placeholder until the Product Assessment POST
392 * response integration is complete.
393 *
394 * Primary product  = the product in filterApiResult whose productVariantCode
395 *                    is NOT in the bundle or cross-sell metadata lists.
396 * Bundle product   = the product whose productVariantCode is in bundleproductvariantcode.
397 * Cross-sell product = the product whose productVariantCode is in crosssellproductvariantcode.
398 *
399 * @param {string} assessmentId - constant placeholder (real value from POST response)
400 * @returns {Object} productAssessmentsParams
401 */
402function buildProductAssessmentParams(assessmentId) {
403  const productVariantCode = getMetadata('productvariantcode') || '';
404  const bundleProductVariantCode = getMetadata('bundleproductvariantcode') || '';
405  const crossSellProductVariantCode = getMetadata('crosssellproductvariantcode') || '';
406
407  const bundleCodes = new Set(
408    bundleProductVariantCode.split(',').map((c) => c.trim()).filter(Boolean),
409  );
410  const crossSellCodes = new Set(
411    crossSellProductVariantCode.split(',').map((c) => c.trim()).filter(Boolean),
412  );
413
414  const products = filterApiResult?.data?.products || [];
415
416  // Find the primary product: variant code is not in bundle or cross-sell sets
417  const primaryProduct = products.find(
418    (p) => determineOptedInType(p.productVariantCode, bundleCodes, crossSellCodes) === 'Primary',
419  ) || {};
420
421  // Find the bundle product: variant code is in bundleCodes
422  const bundleProduct = products.find(
423    (p) => determineOptedInType(p.productVariantCode, bundleCodes, crossSellCodes) === 'Bundled',
424  ) || {};
425
426  // Find the cross-sell product: variant code is in crossSellCodes
427  const crossSellProduct = products.find(
428    (p) => determineOptedInType(p.productVariantCode, bundleCodes, crossSellCodes) === 'CrossSell',
429  ) || {};
430
431  const params = {
432    enterpriseProductId: primaryProduct.enterpriseProductId || '',
433    productId: primaryProduct.productId || '',
434    productVariantCode,
435    bundleProductId: bundleProduct.productId || '',
436    bundleProductVariantCode,
437    crossSellProductId: crossSellProduct.productId || '',
438    crossSellProductVariantCode,
439    assessmentId,
440  };
441
442  return params;
443}
444
445/**
446 * Build the eligibleProducts array from page metadata variant codes.
447 *
448 * @param {string} primaryProductVariantCode
449 * @param {string} bundleProductVariantCode
450 * @param {string} crossSellProductVariantCode
451 * @returns {Array}
452 */
453function buildEligibleProducts(
454  primaryProductVariantCode,
455  bundleProductVariantCode,
456  crossSellProductVariantCode,
457) {
458  const eligibleProduct = { isReferToBranch: false };
459  if (primaryProductVariantCode) eligibleProduct.primaryProductId = primaryProductVariantCode;
460  if (bundleProductVariantCode) eligibleProduct.bundledProductId = bundleProductVariantCode;
461  if (crossSellProductVariantCode) eligibleProduct.crossSellProductId = crossSellProductVariantCode;
462  return [eligibleProduct];
463}
464
465/**
466 * Fire the DDO Product Assessment API POST with a dynamic payload built from
467 * the cached Filter API result and page metadata.
468 * Response is logged only — sessionStorage is still written from mock.
469 *
470 * @param {Array<{id: string, label: string, isSelected: boolean}>} surveyAnswers
471 * @param {string} consentType - 'pageload' | 'linkclick'
472 * @returns {Promise<void>}
473 */
474async function postProductAssessmentApi(surveyAnswers, consentType) {
475  const productGroup = getMetadata('productgroup') || '';
476  const productVariantCode = getMetadata('productvariantcode') || '';
477  const bundleProductVariantCode = getMetadata('bundleproductvariantcode') || '';
478  const crossSellProductVariantCode = getMetadata('crosssellproductvariantcode') || '';
479
480  const bundleCodes = new Set(
481    bundleProductVariantCode
482      .split(',')
483      .map((c) => c.trim())
484      .filter(Boolean),
485  );
486  const crossSellCodes = new Set(
487    crossSellProductVariantCode
488      .split(',')
489      .map((c) => c.trim())
490      .filter(Boolean),
491  );
492
493  const filterData = filterApiResult?.data || {};
494  const optedInProducts = buildOptedInProducts(
495    filterData,
496    surveyAnswers,
497    consentType,
498    bundleCodes,
499    crossSellCodes,
500  );
501  const eligibleProducts = buildEligibleProducts(
502    productVariantCode,
503    bundleProductVariantCode,
504    crossSellProductVariantCode,
505  );
506
507  const body = { productGroup, eligibleProducts, optedInProducts };
508  const messageId = crypto.randomUUID();
509  const correlationId = crypto.randomUUID();
510  const assessmentApiUrl = await loadPlaceholder('Environment+Variables', 'ddo-product-assesment-api');
511
512  const response = await fetch(assessmentApiUrl, {
513    method: 'POST',
514    headers: {
515      'Content-Type': 'application/json',
516      'x-appcorrelationid': correlationId,
517      'x-brandid': 'WBC',
518      'x-channeltype': 'Internal',
519      'x-messageid': messageId,
520      'x-originatingsystemid': PRODUCT_ASSESSMENT_ORIGINATING_SYSTEM_ID,
521    },
522    body: JSON.stringify(body),
523  });
524
525  if (!response.ok) {
526    const errorText = await response.text();
527    // eslint-disable-next-line no-console
528    console.error('ddo-utils.js:postProductAssessmentApi error', response.status, errorText);
529    throw new Error(`DDO Product Assessment API responded with ${response.status} ${response.statusText}`);
530  }
531  const responseText = await response.text();
532
533  try {
534    const responseJson = JSON.parse(responseText);
535    const assessmentId = responseJson?.entities?.productAssessments?.[0]?.id || '';
536    return assessmentId;
537  } catch (err) {
538    // eslint-disable-next-line no-console
539    console.error('ddo-utils.js:postProductAssessmentApi could not parse response JSON', err);
540    return '';
541  }
542}
543
544/**
545 * Utility 2 — DDO Product Assessment API
546 *
547 * Builds a dynamic payload from the cached Filter API result and page metadata,
548 * fires the Product Assessment API POST, persists mock params to sessionStorage,
549 * then optionally calls callMedbFlow().
550 *
551 * Call either:
552 *   a) on page load when consent is NOT mandatory (no .confirmation__consent in DOM), or
553 *   b) from the confirmation CTA click handler when consent IS mandatory.
554 *
555 * @param {Array<{id: string, label: string, isSelected: boolean}>} surveyAnswers
556 * @param {string} [consentType='pageload'] - 'pageload' | 'linkclick'
557 * @param {boolean} [skipMedb=false] - When true, skips callMedbFlow (deferred to CTA click)
558 * @param {HTMLElement|null} [ctaEl=null] - The CTA button element (from the click event context)
559 * @returns {Promise<boolean>} true on success, false on failure
560 */
561export async function callDdoProductAssessmentApi(
562  surveyAnswers,
563  consentType = 'pageload',
564  skipMedb = false,
565  ctaEl = null,
566) {
567  try {
568    // Fire POST with dynamic payload built from filterApiResult — response returns asse
568ssmentId.
569    const assessmentId = await postProductAssessmentApi(surveyAnswers, consentType);
570
571    // Build params dynamically from Filter API result + page metadata.
572    const params = buildProductAssessmentParams(assessmentId || ASSESSMENT_ID_PLACEHOLDER);
573    writeProductAssessmentParams(params);
574    // Only call MEDB immediately when not deferred (linkclick path always calls it)
575    if (!skipMedb) {
576      clearPersistRoot();
577      callMedbFlow(params, ctaEl);
578    }
579    return true;
580  } catch (err) {
581    // eslint-disable-next-line no-console
582    console.error('ddo-utils.js:callDdoProductAssessmentApi error', err);
583    if (isWestpacDomain()) await handleDdoError();
584    return false;
585  }
586}
587
588/**
589 * Read the persisted product assessment params from sessionStorage and call
590 * callMedbFlow with them. Used for the no-consent page-load path where
591 * callMedbFlow is deferred until the CTA is clicked.
592 *
593 * @param {HTMLElement|null} ctaEl - The CTA button element (from the click event context)
594 */
595export function callMedbFlowFromSession(ctaEl) {
596  try {
597    const raw = sessionStorage.getItem('productAssessmentsParams');
598    const params = raw ? JSON.parse(raw) : null;
599    if (params) {
600      clearPersistRoot();
601      callMedbFlow(params, ctaEl);
602    }
603  } catch (err) {
604    // eslint-disable-next-line no-console
605    console.error('ddo-utils.js:callMedbFlowFromSession error', err);
606  }
607}
608
609/**
610 * Update window.pageDetails.
611 */
612function updatePageDetailsForDdoPages() {
613  const productGroup = getMetadata('productgroup') || '';
614  if (!productGroup) return;
615
616  if (!window.pageDetails) {
617    window.pageDetails = {};
618  }
619
620  window.pageDetails.pageName = DATA_LAYER_PAGE_NAME;
621  window.pageDetails.siteName = DATA_LAYER_SITE_NAME;
622  window.pageDetails.pageProperty = productGroup;
623  const productID = buildDdoProductId();
624  const toolOutcome = buildDdoToolOutcome();
625  if (productID.length > 0) {
626    window.pageDetails.productID = productID;
627  }
628  if (toolOutcome) {
629    window.pageDetails.toolOutcome = toolOutcome;
630  }
631  addDdoEventKey(window.pageDetails);
632}
633
634async function continueDdoProductAssessmentFlow(doc) {
635  const assessmentSuccess = await callDdoProductAssessmentApi([], 'pageload', true);
636  if (!assessmentSuccess) return;
637
638  const ctaElements = doc.querySelectorAll('.confirmation__cta');
639  ctaElements.forEach((ctaElement) => {
640    const handleCtaClick = (event) => {
641      if (!validateSelectionConfigurationDropdowns(doc)) {
642        event.preventDefault();
643        return;
644      }
645
646      ctaElement.removeEventListener('click', handleCtaClick);
647      callMedbFlowFromSession(ctaElement);
648    };
649
650    ctaElement.addEventListener('click', handleCtaClick);
651  });
652}
653
654/**
655 * Initialises DDO page details from the filter API. The product assessment API
656 * continues independently because it is not required by page-view analytics.
657 *
658 * @param {Document} doc - Document context
659 */
660export async function initDdoPageFlow(doc) {
661  const templateType = getMetadata('templatetype');
662  if (templateType !== 'ddo') return;
663  const filterApiSuccess = await initDdoFilterApi();
664  if (!filterApiSuccess) return;
665  updatePageDetailsForDdoPages();
666  const isConsentMandatory = !!doc.querySelector('.confirmation__consent');
667  if (!isConsentMandatory) {
668    continueDdoProductAssessmentFlow(doc).catch((error) => {
669      // eslint-disable-next-line no-console
670      console.error('ddo-utils.js:continueDdoProductAssessmentFlow error', error);
671    });
672  }
673}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.