1import { FranklinBlock, getAvailableChildrenRow } from '../../scripts/lib-franklin.js'; 2import { getFormsEnv, FORMS_API_ENDPOINT_PROD, FORMS_API_ENDPOINT_UAT } from '../../scripts/lib-franklin/forms-service.js'; 3 4const CONFIG_TOKEN_PROD_DEFAULT = 'paxlovid-pharma-finder-generic-prod'; 5const CONFIG_TOKEN_UAT_DEFAULT = 'paxlovid-pharma-finder-generic'; 6 7const DEFAULT_REQUESTED_SERVICE = 'PharmacyFinder'; 8const DEFAULT_HEADLINE = 'Find a Pharmacy'; 9const CONTENT_HEIGHT_MESSAGE = 'CONTENT_HEIGHT_UPDATED'; 10 11const isUat = () => getFormsEnv() !== 'production'; 12 13/** 14 * Fetch the embed URL from the Pfizer Forms Service. The service authenticates 15 * with AssistRX server-side and returns the iframe URL for the finder tool. 16 * 17 * Both requests are sent to the same environment-resolved endpoint so the CSRF 18 * token primed by the GET stays valid for the POST (the shared token-substring 19 * endpoint selection does not recognise the `-prod` config-token suffix). 20 */ 21const requestPharmacyFinder = async (token, requestedService) => { 22 const endpoint = isUat() ? FORMS_API_ENDPOINT_UAT : FORMS_API_ENDPOINT_PROD; 23 const headers = { 24 'Content-Type': 'application/json', 25 'x-config-token': token, 26 }; 27 28 // Prime the CSRF token from the same endpoint the POST will use. 29 const primeResp = await fetch(endpoint, { method: 'GET', cache: 'no-cache', headers }); 30 if (!primeResp.ok) { 31 throw new Error(`Pharmacy finder config request failed (${primeResp.status})`); 32 } 33 const primed = await primeResp.json(); 34 const csrfToken = primed?.data?.csrfToken ?? null; 35 36 const resp = await fetch(endpoint, { 37 method: 'POST', 38 cache: 'no-cache', 39 headers, 40 body: JSON.stringify({ 41 requestedServices: [requestedService], 42 csrfToken, 43 }), 44 }); 45 46 if (!resp.ok) { 47 throw new Error(`Pharmacy finder request failed (${resp.status})`); 48 } 49 50 return resp.json(); 51}; 52 53export default class PharmacyFinder extends FranklinBlock { 54 constructor(blockName, block) { 55 super(blockName, block); 56 this.config = {}; 57 this.onContentHeightMessage = null; 58 } 59 60 async beforeBlockDataRead() { 61 const read = (name) => { 62 const row = getAvailableChildrenRow(this.block, name); 63 return row ? row.textContent.trim() : null; 64 }; 65 66 this.config = { 67 headline: read('headline'), 68 requestedService: read('requested_service') || DEFAULT_REQUESTED_SERVICE, 69 configTokenProd: read('config_token_prod') || CONFIG_TOKEN_PROD_DEFAULT, 70 configTokenUat: read('config_token_uat') || CONFIG_TOKEN_UAT_DEFAULT, 71 }; 72 } 73 74 renderIframe(iframeSrc) { 75 const wrapper = document.createElement('div'); 76 wrapper.classList.add('core-pharmacy-finder-iframe-wrapper'); 77 78 const iframe = document.createElement('iframe'); 79 iframe.classList.add('core-pharmacy-finder-iframe'); 80 iframe.setAttribute('title', this.config.headline || DEFAULT_HEADLINE); 81 iframe.setAttribute('loading', 'lazy'); 82 iframe.src = iframeSrc; 83 84 wrapper.append(iframe); 85 return { wrapper, iframe }; 86 } 87 88 // The embedded tool reports its own height; mirror it onto the iframe so there 89 // is no inner scrollbar. The handler is stored so it can be removed and to 90 // avoid stacking duplicate listeners on re-render. 91 listenForContentHeight(iframe, iframeSrc) { 92 let iframeOrigin = null; 93 try { 94 iframeOrigin = new URL(iframeSrc, window.location.href).origin; 95 } catch { 96 iframeOrigin = null; 97 } 98 99 if (this.onContentHeightMessage) { 100 window.removeEventListener('message', this.onContentHeightMessage); 101 } 102 103 this.onContentHeightMessage = (event) => { 104 if (!iframeOrigin || event.origin !== iframeOrigin) return; 105 // If more than one finder shares the AssistRX origin on a page, only resize 106 // the iframe that actually sent the message. 107 if (event.source && iframe.contentWindow && event.source !== iframe.contentWindow) return; 108 const { messageType, height } = event.data || {}; 109 if (messageType === CONTENT_HEIGHT_MESSAGE && Number.isFinite(height) && height > 0) { 110 iframe.style.height = `${height}px`; 111 } 112 }; 113 114 window.addEventListener('message', this.onContentHeightMessage); 115 } 116 117 renderError(error) { 118 console.error('Error loading pharmacy finder', error); 119 this.block.classList.add('core-pharmacy-finder-error'); 120 121 const message = document.createElement('p'); 122 message.classList.add('core-pharmacy-finder-error-message'); 123 message.setAttribute('role', 'alert'); 124 message.textContent = 'The pharmacy finder is temporarily unavailable. Please try again later.'; 125 this.block.append(message); 126 } 127 128 async afterBlockRender() { 129 this.block.textContent = ''; 130 131 if (this.config.headline) { 132 const headline = document.createElement('h2'); 133 headline.classList.add('core-pharmacy-finder-headline'); 134 headline.textContent = this.config.headline; 135 this.block.append(headline); 136 } 137 138 try { 139 const token = isUat() ? this.config.configTokenUat : this.config.configTokenProd; 140 const response = await requestPharmacyFinder(token, this.config.requestedService); 141 const iframeSrc = response?.data?.[0]?.details?.url; 142 143 if (!iframeSrc) { 144 throw new Error('Pharmacy finder response did not include an iframe source'); 145 } 146 147 const { wrapper, iframe } = this.renderIframe(iframeSrc); 148 this.block.append(wrapper); 149 this.listenForContentHeight(iframe, iframeSrc); 150 } catch (error) { 151 this.renderError(error); 152 } 153 154 this.block.classList.add('block-padding-standard'); 155 this.block.classList.add('block-focus-visible'); 156 } 157}
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.