This adds a circuit breaker for uncaught failures in beforeEach. The first failure still goes through the normal ERROR/RESTART recovery path; a second consecutive failure redirects to the static error page instead of looping. - New before-each-error-recovery module tracks recovery attempts in sessionStorage (with an in-memory fallback). - ERROR/RESTART routes skip normal guard logic so the counter can accumulate across bounce-backs. - The counter clears only after a fully successful navigation. - redirectToStaticErrorPage centralizes hard-error bailout: logs, clears poisoned sessionStorage keys (submittedState, heritage redirect count, recovery count, externalParameterState), then navigates to /fmg/static/error. - The static error page RESTART flow clears the same sessionStorage keys so bad state does not re-enter the funnel. - Session storage key strings are centralized in session-storage.js. - Unit tests cover recovery counting, bailout clearing, and navigation fallbacks. Expected impact: Stops repeated ERROR → RESTART → ERROR cycles (including corrupt submittedState JSON.parse failures and recurring automation errors in us-east-1/us-east-2) by bailing out to the static error page after one failed recovery attempt.
452 lines
No EOL
18 KiB
HTML
452 lines
No EOL
18 KiB
HTML
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<style>
|
|
|
|
@font-face {
|
|
font-family: Urbanist;
|
|
src: url("/fmg/static/assets/Urbanist-Regular.woff") format("woff");
|
|
}
|
|
|
|
@font-face {
|
|
font-family: UrbanistSemiBold;
|
|
src: url("/fmg/static/assets/Urbanist-SemiBold.woff") format("woff");
|
|
}
|
|
|
|
body {
|
|
font-family: 'Urbanist', Roboto, Arial, Helvetica, sans-serif;
|
|
}
|
|
|
|
.container {
|
|
max-width: 960px;
|
|
margin: auto;
|
|
padding: 1rem;
|
|
}
|
|
|
|
.header, .copy, .cta {
|
|
margin-bottom: 3rem;
|
|
}
|
|
|
|
.header .header-image {
|
|
width: 165px;
|
|
}
|
|
|
|
.subheader {
|
|
font-weight: normal;
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.cta-button {
|
|
padding-top: 0.75rem;
|
|
padding-bottom: 0.75rem;
|
|
padding-left: 2rem;
|
|
padding-right: 2rem;
|
|
border-radius: 50rem;
|
|
background-color: #db0020;
|
|
color: #ffffff;
|
|
font-family: inherit;
|
|
font-size: 1rem;
|
|
border: 0px;
|
|
}
|
|
|
|
.diagnostic-info {
|
|
color: #db0020;
|
|
font-family: 'UrbanistSemiBold', Roboto, Arial, Helvetica, sans-serif;
|
|
}
|
|
</style>
|
|
<title>Error - Safelite</title>
|
|
<link rel="icon" href="/fmg/favicon.ico">
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<div class="header">
|
|
<img class="header-image" src="/fmg/static/assets/logo.svg" />
|
|
</div>
|
|
<div class="copy">
|
|
<h2 class="subheader">
|
|
We're not able to schedule at this time. We apologize for the inconvenience
|
|
</h2>
|
|
<p>
|
|
We encountered an error while processing your appointment. You can return to our site and try scheduling again below.
|
|
</p>
|
|
</div>
|
|
<div class="cta">
|
|
<a href="/">
|
|
<button class="cta-button">
|
|
Continue to Safelite.com
|
|
</button>
|
|
</a>
|
|
</div>
|
|
<div class="diagnostic-info" id="diagnostic-container">
|
|
<hr />
|
|
<h2 class="subheader">
|
|
Bailout Information:
|
|
</h2>
|
|
<div id="bailout-info-container">
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
// Helpers
|
|
function getCookieValueByName(name) {
|
|
const value = "; " + document.cookie;
|
|
const parts = value.split("; " + name + "=");
|
|
|
|
if (parts.length === 2) {
|
|
return parts.pop().split(";").shift();
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function getCurrentEnvironmentData() {
|
|
const environmentData = [
|
|
{
|
|
name: 'Localhost',
|
|
hostName: 'localhost',
|
|
apiHostname: 'digitalapi.dev.safelite.io',
|
|
debug: true,
|
|
},
|
|
{
|
|
name: 'Dev',
|
|
hostName: 'www-dev2.safelite.com',
|
|
apiHostname: 'digitalapi.dev.safelite.io',
|
|
debug: true,
|
|
},
|
|
{
|
|
name: 'SysTest',
|
|
hostName: 'www-test2.safelite.com',
|
|
apiHostname: 'digitalapi.test.safelite.io',
|
|
debug: true,
|
|
},
|
|
{
|
|
name: 'QA',
|
|
hostName: 'www-qa2.safelite.com',
|
|
apiHostname: 'digitalapi.qa.safelite.io',
|
|
debug: false,
|
|
},
|
|
{
|
|
name: 'Prod',
|
|
hostName: 'www.safelite.com',
|
|
apiHostname: 'digitalapi.safelite.io',
|
|
debug: false,
|
|
},
|
|
];
|
|
|
|
const hostName = window.location.hostname;
|
|
|
|
const match = environmentData.find(
|
|
data => (data.hostName === hostName)
|
|
);
|
|
|
|
return match;
|
|
}
|
|
|
|
function getExperimentSettingsFromVuex(vuexData) {
|
|
const experiments = vuexData?.applicationUser?.experiments;
|
|
|
|
if(experiments) {
|
|
return experiments
|
|
.filter((e) => !!e.isActive)
|
|
.map((e) => e.settings)
|
|
.reduce((prev, next) => Object.assign(prev, next), {}) ?? {};
|
|
}
|
|
|
|
return {};
|
|
}
|
|
|
|
function extractDataFromVuex(existingVuexData) {
|
|
let bailoutInfo = [];
|
|
let headerInfo = [];
|
|
|
|
try {
|
|
// =============== Collect diagnostic data:
|
|
bailoutInfo = [];
|
|
|
|
// App & Site Name
|
|
bailoutInfo.push({ name: 'App Name', value: 'FixMyGlass' });
|
|
bailoutInfo.push({ name: 'Site Name', value: 'SafeliteDotcom' });
|
|
|
|
// Not included (yet) from heritage:
|
|
// - Site ID
|
|
// - Code
|
|
// - Module
|
|
// - Page
|
|
|
|
bailoutInfo.push({ name: 'Timestamp', value: `${new Date()}` });
|
|
|
|
// - URL
|
|
// - Server
|
|
|
|
const sessionId = getCookieValueByName('sid');
|
|
bailoutInfo.push({ name: 'Session Id', value: sessionId });
|
|
|
|
const userAgent = window.navigator.userAgent;
|
|
bailoutInfo.push({ name: 'User Agent', value: userAgent });
|
|
|
|
// - IP
|
|
// - Session Log Sequence Number
|
|
|
|
bailoutInfo.push({ name: 'Referral Seq Number', value: existingVuexData.order?.referralSequenceNumber });
|
|
bailoutInfo.push({ name: 'Referral Number', value: existingVuexData.order?.referralNumber });
|
|
bailoutInfo.push({ name: 'Referral Date', value: existingVuexData.order?.referralDate });
|
|
bailoutInfo.push({ name: 'Referral Provider Number', value: existingVuexData.order?.serviceLocation?.provider?.providerNumber });
|
|
bailoutInfo.push({ name: 'Referral CTU', value: existingVuexData.order?.serviceLocation?.provider?.address?.zipCodeCtu });
|
|
|
|
// - Referral Insured Zip
|
|
// - Referral Insured Service Zip
|
|
|
|
bailoutInfo.push({ name: 'Referral CarID', value: existingVuexData.order?.vehicle?.carId });
|
|
|
|
const vehicle = existingVuexData.order?.vehicle;
|
|
const vehicleString = vehicle?.year
|
|
? `${vehicle.year} ${vehicle.make} ${vehicle.model} - ${vehicle.style}`
|
|
: null;
|
|
bailoutInfo.push({ name: 'Referral Vehicle', value: vehicleString });
|
|
bailoutInfo.push({ name: 'Work Order Number', value: existingVuexData.order?.workOrderNumber });
|
|
bailoutInfo.push({ name: 'Work Order ID', value: existingVuexData.order?.workOrderId });
|
|
bailoutInfo.push({ name: 'Parent Account Number', value: existingVuexData.order?.payment?.parentAccountNumber });
|
|
|
|
const environmentData = getCurrentEnvironmentData();
|
|
const userIdCookieName = `FunnelUserId-${environmentData?.name}`;
|
|
bailoutInfo.push({ name: 'User Id', value: getCookieValueByName(userIdCookieName) });
|
|
|
|
// - Parent Account Name
|
|
// - Client GUID
|
|
|
|
// =============== Collect header data:
|
|
headerInfo = [];
|
|
|
|
headerInfo.push({ name: 'X-Experiment-Data', value: JSON.stringify(getExperimentSettingsFromVuex(existingVuexData)) });
|
|
headerInfo.push({ name: 'X-Application-Name', value: 'FixMyGlass' });
|
|
|
|
const sessionKeyCookieName = `FunnelSessionKey-${environmentData?.name}`;
|
|
|
|
headerInfo.push({ name: 'X-Session-Sequence-Number', value: getCookieValueByName(sessionKeyCookieName) });
|
|
headerInfo.push({ name: 'X-Referral-Sequence-Number', value: existingVuexData?.order?.referralSequenceNumber });
|
|
headerInfo.push({ name: 'X-Enterprise-Order-Number', value: existingVuexData?.order?.eon });
|
|
headerInfo.push({ name: 'log-enabled', value: existingVuexData?.applicationUser?.loggingOption ?? false });
|
|
} finally {
|
|
return {
|
|
headerInfo: headerInfo,
|
|
bailoutInfo: bailoutInfo
|
|
};
|
|
}
|
|
}
|
|
|
|
function generateRequestHeader(headerInfo) {
|
|
let headers = {};
|
|
|
|
headerInfo.forEach(
|
|
keyPair => {
|
|
headers[keyPair.name] = keyPair.value;
|
|
}
|
|
);
|
|
|
|
headers['X-Transaction-Id'] = crypto.randomUUID();
|
|
|
|
headers['Content-Type'] = 'application/json';
|
|
|
|
return headers;
|
|
}
|
|
|
|
function getFieldWithName(info, name) {
|
|
const match = info?.find(
|
|
(row) => row.name === name
|
|
);
|
|
|
|
return match?.value;
|
|
}
|
|
|
|
// Refactor opportunity: sessionStorage keys and cookie names below are duplicated from the
|
|
// Vue app because this static page is not webpack-bundled. Canonical sources:
|
|
// - src/constants/session-storage.js (sessionStorageKeyConstants)
|
|
// - src/constants/cookie-names.js (cookieNames; env-suffixed funnel cookies)
|
|
// - src/router/methods/error.js (clearBailoutSessionStorage — keep in sync on bailout keys)
|
|
// Options: build-time inject into this file, shared plain JS under public/static/, or generate
|
|
// from constants during CI. Until then, update all three places when keys change.
|
|
function clearApplicationData() {
|
|
window.localStorage.removeItem('vuex');
|
|
|
|
// Keys match sessionStorageKeyConstants in src/constants/session-storage.js and
|
|
// clearBailoutSessionStorage() in src/router/methods/error.js — keep in sync.
|
|
const sessionStorageKeysToClear = [
|
|
'submittedState',
|
|
'heritageSuppressRedirectCount',
|
|
'beforeEachErrorRecoveryCount',
|
|
'externalParameterState',
|
|
];
|
|
// Clear sessionStorage keys that can re-poison the funnel on re-entry.
|
|
sessionStorageKeysToClear.forEach((key) => {
|
|
try {
|
|
window.sessionStorage.removeItem(key);
|
|
} catch {
|
|
// sessionStorage unavailable
|
|
}
|
|
});
|
|
|
|
const environmentData = getCurrentEnvironmentData();
|
|
// Funnel cookie prefixes match cookie-names.js; suffix is environmentData.name here
|
|
// (equivalent to applicationConfig.CURRENT_ENVIRONMENT in the Vue app).
|
|
const cookieNames = [
|
|
`FunnelUserId-${environmentData?.name}`,
|
|
`FunnelSessionKey-${environmentData?.name}`,
|
|
`FunnelSessionInfo-${environmentData?.name}`,
|
|
`sid`,
|
|
`dxdev`,
|
|
];
|
|
|
|
cookieNames.forEach(
|
|
(cookieName) => {
|
|
let cookieToAdd = `${cookieName}=undefined; path=/; domain=${environmentData.hostName}; max-age=0`;
|
|
document.cookie = cookieToAdd;
|
|
}
|
|
)
|
|
}
|
|
|
|
window.onload = async () => {
|
|
// =============== Check for session info:
|
|
const existingVuexDataJSON = window.localStorage.getItem('vuex');
|
|
const existingVuexData = existingVuexDataJSON ? JSON.parse(existingVuexDataJSON) : null;
|
|
const existingBailoutInfoJSON = window.sessionStorage.getItem('bailoutInfo');
|
|
const existingBailoutInfo = existingBailoutInfoJSON ? JSON.parse(existingBailoutInfoJSON) : null;
|
|
const existingHeaderInfoJSON = window.sessionStorage.getItem('headerInfo');
|
|
const existingHeaderInfo = existingHeaderInfoJSON ? JSON.parse(existingHeaderInfoJSON) : null;
|
|
|
|
console.log(`================ VUEX DATA`);
|
|
console.log(existingVuexData);
|
|
console.log(`================ PRIOR BAILOUT DATA`);
|
|
console.log(existingBailoutInfo);
|
|
console.log(`================ PRIOR HEADER DATA`);
|
|
console.log(existingHeaderInfo);
|
|
|
|
let bailoutInfo = null;
|
|
let headerInfo = null;
|
|
|
|
if(existingVuexData) {
|
|
const results = extractDataFromVuex(existingVuexData);
|
|
bailoutInfo = results.bailoutInfo;
|
|
headerInfo = results.headerInfo;
|
|
|
|
clearApplicationData();
|
|
|
|
if(bailoutInfo) {
|
|
window.sessionStorage.setItem('bailoutInfo', JSON.stringify(bailoutInfo));
|
|
}
|
|
|
|
if(headerInfo) {
|
|
window.sessionStorage.setItem('headerInfo', JSON.stringify(headerInfo));
|
|
}
|
|
} else {
|
|
bailoutInfo = existingBailoutInfo ?? [];
|
|
headerInfo = existingHeaderInfo ?? [];
|
|
}
|
|
|
|
const environmentInfo = getCurrentEnvironmentData();
|
|
const shouldShowDebugInfo = environmentInfo?.debug;
|
|
|
|
if(bailoutInfo && shouldShowDebugInfo) {
|
|
try {
|
|
// =============== Display diagnostic data:
|
|
// Create display nodes
|
|
const elements = bailoutInfo.map(
|
|
dataPoint => {
|
|
const element = document.createElement('li');
|
|
element.textContent = `${dataPoint.name}: ${dataPoint.value}`;
|
|
|
|
return element;
|
|
}
|
|
);
|
|
|
|
const fragment = new DocumentFragment();
|
|
|
|
elements.forEach(
|
|
(element) => {
|
|
fragment.append(element);
|
|
}
|
|
);
|
|
|
|
// Attach nodes to DOM and render
|
|
const attachNode = document.getElementById('bailout-info-container');
|
|
if(attachNode) {
|
|
const ul = attachNode.appendChild(document.createElement('ul'));
|
|
ul.append(fragment);
|
|
}
|
|
} catch(e) {
|
|
console.error(`=== ERROR DISPLAYING INFO`);
|
|
console.error(e);
|
|
|
|
const diagnosticContainer = document.getElementById('diagnostic-container');
|
|
diagnosticContainer.remove();
|
|
}
|
|
} else {
|
|
const diagnosticContainer = document.getElementById('diagnostic-container');
|
|
diagnosticContainer.remove();
|
|
}
|
|
|
|
const apiHostname = environmentInfo?.apiHostname;
|
|
if(apiHostname) {
|
|
try {
|
|
const endpointUrl = `https://${apiHostname}/analytics/api/v1/logging/log-error`;
|
|
|
|
const infoString = bailoutInfo?.map(
|
|
(entry) => `${entry.name}: ${entry.value}`
|
|
)?.reduce(
|
|
(prev, next) => `${prev}\n${next}`
|
|
);
|
|
const entryString = `User encountered bailout page.\n${new Date()}\n${infoString ?? 'No information recoverable'}`;
|
|
|
|
const headers = generateRequestHeader(headerInfo);
|
|
|
|
const request = new Request(endpointUrl, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: JSON.stringify({
|
|
entry: entryString,
|
|
}),
|
|
});
|
|
|
|
const response = await fetch(request);
|
|
// Don't need data from response, but could get it here with:
|
|
// const responseData = await response.json();
|
|
} catch(e) {
|
|
console.error(`=== ERROR SENDING LOGGING`);
|
|
console.error(e);
|
|
}
|
|
|
|
try {
|
|
const endpointUrl = `https://${apiHostname}/analytics/api/v1/analytics/log-page-view`;
|
|
|
|
const headers = generateRequestHeader(headerInfo);
|
|
|
|
const payload = {
|
|
AppName: "FixMyGlass",
|
|
action: "",
|
|
applicationName: "FixMyGlassNextGen",
|
|
event: "ENTRY",
|
|
experimentsForUser: [],
|
|
pageName: "static-error",
|
|
parentAccountNumber: getFieldWithName(bailoutInfo, 'Parent Account Number') ?? 0,
|
|
referralSequenceNumber: getFieldWithName(bailoutInfo, 'Referral Seq Number'),
|
|
sessionId: getFieldWithName(bailoutInfo, 'Session Id'),
|
|
sessionKey: getFieldWithName(headerInfo, 'X-Session-Sequence-Number'),
|
|
shouldUseSessionId: false,
|
|
userId: getFieldWithName(bailoutInfo, 'User Id'),
|
|
};
|
|
|
|
const request = new Request(endpointUrl, {
|
|
method: 'POST',
|
|
headers: headers,
|
|
body: JSON.stringify(payload),
|
|
});
|
|
|
|
const response = await fetch(request);
|
|
} catch(e) {
|
|
console.error(`=== ERROR SENDING PAGEVIEW`);
|
|
console.error(e);
|
|
}
|
|
}
|
|
};
|
|
</script>
|
|
</body>
|
|
</html> |