|
// by dapper |
|
// to minify: |
|
// terser amex-load-offer.js --mangle -c unsafe_arrows=true,unsafe_comps=true,unsafe_math=true,unsafe_methods=true,unsafe_proto=true,keep_fargs=false,passes=3,drop_debugger=false -f quote_style=3,wrap_func_args=false,ascii_only=true,ecma=2023 | sed 's/\%/\%25/g ; s/^\!/javascript:void /' |
|
|
|
(async function() { |
|
// The URL of the American Express dashboard. |
|
const DASHBOARD_HREF = "https://global.americanexpress.com/dashboard"; |
|
|
|
// Gets the __INITIAL_STATE__ variable, as given by American Express. |
|
async function getRawInitialState() { |
|
const response = await fetch(DASHBOARD_HREF); |
|
const text = await response.text(); |
|
const parsedDoc = new DOMParser().parseFromString(text, "text/html"); |
|
const initialState = parsedDoc.getElementById("initial-state"); |
|
const stateJSON = initialState.innerHTML.match(/\_\_INITIAL\_STATE\_\_\s*=\s*([^\n]+)\s*;(?:\n|$)/)[1]; |
|
return JSON.parse(JSON.parse(stateJSON)); |
|
} |
|
|
|
// Parses an array of the form ["prop1", "value1", "prop2", "value2"] into an object, starting at the given offset. |
|
// Will optionally use a function to transform the values. |
|
function parseArrayIntoObject(array, offset, valueMapper = v => v) { |
|
const outObject = {}; |
|
for (let i = offset; i < array.length; i += 2) { |
|
outObject[array[i]] = valueMapper(array[i + 1]); |
|
} |
|
return outObject; |
|
} |
|
|
|
// Turns an __INITIAL_STATE__-formatted variable into a more usable format. |
|
function parseRawInitialState(state) { |
|
if (Array.isArray(state) && state[0] === "~#iM") { |
|
return parseArrayIntoObject(state[1], 0, parseRawInitialState); |
|
} |
|
if (Array.isArray(state) && state[0] === "^ ") { |
|
return parseArrayIntoObject(state, 1, parseRawInitialState); |
|
} |
|
if (Array.isArray(state)) { |
|
return state.map(value => parseRawInitialState(value)); |
|
} |
|
return state; |
|
} |
|
|
|
// Makes a request to a functions.americanexpress.com API. |
|
async function makeFunctionRequest(path, body) { |
|
const response = await fetch("https://functions.americanexpress.com/" + path, { |
|
method: "POST", |
|
headers: { |
|
"Content-Type": "application/json" |
|
}, |
|
credentials: "include", |
|
body: JSON.stringify(body) |
|
}); |
|
const json = await response.json(); |
|
return json; |
|
} |
|
|
|
// Pads a number with a leading zero, if necessary, to create a two-digit number. |
|
function padTens(num) { |
|
return num.toString().padStart(2, "0"); |
|
} |
|
|
|
// Returns the timezone of the browser in a way that American Express likes. |
|
function getUserOffset() { |
|
const tz = new Date().getTimezoneOffset(); |
|
const tzSign = tz >= 0 ? "-" : "+"; |
|
const hours = padTens(Math.floor(Math.abs(tz) / 60)); |
|
const minutes = padTens(Math.abs(tz) % 60); |
|
return tzSign + hours + ":" + minutes; |
|
} |
|
|
|
// Creates a date string in the American Express format. |
|
function getDateTimeWithOffset() { |
|
const date = new Date(); |
|
const year = date.getFullYear(); |
|
const month = padTens(date.getMonth() + 1); |
|
const day = padTens(date.getDay()); |
|
const hour = padTens(date.getHours()); |
|
const minute = padTens(date.getMinutes()); |
|
const second = padTens(date.getSeconds()); |
|
const offset = getUserOffset(); |
|
return `${year}-${month}_${day}T${hour}:${minute}:${second}${offset}`; |
|
} |
|
|
|
// Creates a <td> element with the specified content (either text or HTML). |
|
function createTd(content, isHTML = false) { |
|
const td = document.createElement("td"); |
|
if (isHTML) { |
|
td.innerHTML = content; |
|
} else { |
|
td.textContent = content; |
|
} |
|
return td; |
|
} |
|
|
|
// Creates the display text for a card, e.g. "Business Platinum 12345". |
|
function createCardDescription(cardID) { |
|
return `${cardIDMeta[cardID].product.description.replace(/\s*(?:Card|Honors|Bonvoy|SkyMiles)\s*/g, " ").replace(/\s+/g, " ")} ${cardIDMeta[cardID].account.display_account_number}`; |
|
} |
|
|
|
// Turns an array of card IDs into text displaying the list of cards, e.g. "Business Platinum 12345, Business Gold 23456 (2 cards)". |
|
// If "standalone," will use angle brackets to represent special values or messages. |
|
function createCardList(cards, standalone) { |
|
if (cards.length) { |
|
return `${cards.map(cardID => createCardDescription(cardID)).join(", ")} (${cards.length} card${cards.length === 1 ? "" : "s"})`; |
|
} else { |
|
return standalone ? "<no cards>" : "no cards"; |
|
} |
|
} |
|
|
|
if (location.hostname !== "global.americanexpress.com" || !["/dashboard", "/overview"].includes(location.pathname)) { |
|
if (confirm(`You must be on ${DASHBOARD_HREF} to use this bookmarklet. Would you like to go there now?`)) { |
|
location.href = DASHBOARD_HREF; |
|
} |
|
return; |
|
} |
|
|
|
const initialState = parseRawInitialState(await getRawInitialState()); |
|
const cardIDMeta = initialState.modules["axp-myca-root"].products.details.types.CARD_PRODUCT.productsList; |
|
const cardIDs = Object.keys(cardIDMeta); |
|
const userOffset = getUserOffset(); |
|
const offerIDMap = new Map(); |
|
for (const cardID of cardIDs) { |
|
const response = await makeFunctionRequest("ReadCardAccountOffersList.v1", { |
|
accountNumberProxy: cardID, |
|
locale: "en-US", |
|
source: "STANDARD", |
|
typeOf: "MERCHANT", |
|
status: ["ELIGIBLE", "ENROLLED"], |
|
offerRequestType: "LIST", |
|
userOffset |
|
}); |
|
// todo: The request above errors out when using a non-AO-eligible card, and there should be some way to detect that in the initial state. |
|
// For now, we just use ?? [] to ignore the card. |
|
for (const offer of response?.offers ?? []) { |
|
if (!offer.achievement_type) continue; |
|
const offerDescriptor = offerIDMap.get(offer.id) ?? {enrolled: [], cards: []}; |
|
offerDescriptor.offer = offer; |
|
if (offer.status === "ENROLLED") offerDescriptor.enrolled.push(cardID); |
|
else offerDescriptor.cards.push(cardID); |
|
offerIDMap.set(offer.id, offerDescriptor); |
|
} |
|
} |
|
|
|
const marginStyle = " style=\"margin:4px\""; |
|
const ui = document.createElement("div"); |
|
ui.innerHTML = `<p style="text-align:center"><input type=button value=Close class=close-btn${marginStyle}><input type=button value="Add All" class=add-all-btn${marginStyle}></p><table${marginStyle}><thead><tr><th scope=col>Description</th><th scope=col>Available On</th><th scope=col>Add</th></thead><tbody></tbody></table><p style="text-align:center"><input type=button value=Close class=close-btn ${marginStyle}></p>`; |
|
ui.style.position = "fixed"; |
|
ui.style.width = ui.style.height = "100%"; |
|
ui.style.top = ui.style.left = "0"; |
|
ui.style.backgroundColor = "rgba(255,255,255,0.9)"; |
|
ui.style.zIndex = "999"; |
|
ui.style.overflow = "auto"; |
|
ui.style.padding = "8px"; |
|
const tbody = ui.getElementsByTagName("tbody")[0]; |
|
const closeButtons = ui.getElementsByClassName("close-btn"); |
|
for (const closeButton of closeButtons) { |
|
closeButton.addEventListener("click", function() { |
|
if (ui.parentElement) { |
|
document.documentElement.style.overflow = "auto"; |
|
ui.parentElement.removeChild(ui); |
|
} |
|
}); |
|
} |
|
const addAllButton = ui.getElementsByClassName("add-all-btn")[0]; |
|
addAllButton.addEventListener("click", function() { |
|
if (this.parentElement) this.parentElement.removeChild(this); |
|
const interval = setInterval(_ => { |
|
const {done, value} = offerExecutors.values().next(); |
|
if (done) { |
|
clearInterval(interval); |
|
} |
|
if (value) { |
|
offerExecutors.delete(value); |
|
value(); |
|
} |
|
}, 500); |
|
}); |
|
let offerExecutors = new Set(); |
|
for (const offer of offerIDMap.values()) { |
|
const tr = document.createElement("tr"); |
|
tr.appendChild(createTd(`${offer.offer.name}: ${offer.offer.short_description}`, false)); |
|
tr.appendChild(createTd(createCardList(offer.cards, true), false)); |
|
if (offer.enrolled.length) { |
|
const messageTd = createTd(`<already enrolled on ${createCardList(offer.enrolled, false)}>`, false); |
|
tr.appendChild(messageTd); |
|
tbody.appendChild(tr); |
|
} else { |
|
const buttonTd = createTd(`<input type=button value=Add${marginStyle}>`, true); |
|
const executor = (function() { |
|
offerExecutors.delete(executor); |
|
const parent = this.parentElement; |
|
if (parent) parent.removeChild(this); else return; |
|
const requestDateTimeWithOffset = getDateTimeWithOffset(); |
|
const userOffset = getUserOffset(); |
|
for (const card of offer.cards) { |
|
makeFunctionRequest("CreateCardAccountOfferEnrollment.v1", { |
|
accountNumberProxy: card, |
|
identifier: offer.offer.id, |
|
locale: "en-US", |
|
requestDateTimeWithOffset, |
|
userOffset |
|
}).then(data => { |
|
if (parent.children.length) { |
|
parent.appendChild(document.createTextNode(", ")); |
|
} |
|
const span = document.createElement("span"); |
|
span.style.fontFamily = "monospace"; |
|
if (data.isEnrolled) { |
|
span.style.color = "#0CC00C"; |
|
span.textContent = `success (${createCardDescription(card)})`; |
|
} else { |
|
span.style.color = "#C00C0C"; |
|
span.textContent = `error: ${data.explanationMessage} (${createCardDescription(card)})`; |
|
} |
|
parent.appendChild(span); |
|
}); |
|
} |
|
}).bind(buttonTd.children[0]); |
|
offerExecutors.add(executor); |
|
buttonTd.children[0].addEventListener("click", executor); |
|
tr.appendChild(buttonTd); |
|
tbody.prepend(tr); |
|
} |
|
} |
|
for (let i = 0; i < tbody.children.length; i += 2) { |
|
tbody.children[i].style.backgroundColor = "rgba(0,0,0,0.2)"; |
|
} |
|
document.documentElement.style.overflow = "hidden"; |
|
document.body.appendChild(ui); |
|
})(); |
Have an offer on 3 of my cards. The applet won't show the Add option because it reports them already added to another account-- however those are authorized user accounts. Can the Add button be exposed for this case? I modified the Applet to force this and it worked for 2 cards. Then I manually added the 3rd and amex allowed it.