mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-11-11 11:47:44 +00:00
964 lines
35 KiB
TypeScript
964 lines
35 KiB
TypeScript
import { CATALOG, SINGLE_MONTH, offerTotal, savingPercent, type Offer, type Price, type Total } from "./catalog.js";
|
|
import { minimumFor, perkAmount } from "./crowdfunding.js";
|
|
import { generate, hash } from "./codes.js";
|
|
import { Flow, type CheckoutOutcome, type Selection } from "./flow.js";
|
|
import { applyView, historyRows, selectionFromOrder, type PaymentView, withoutDestination } from "./order.js";
|
|
import { money, moneyCompact } from "./format.js";
|
|
import * as api from "./api.js";
|
|
import { appCodeLink, readHash, resolveLoad } from "./routing.js";
|
|
import * as screens from "./screens.js";
|
|
import { appearanceFor, cardPlan, loadStripeJs, mountCard, publishableKey, type CardFailure, type ConfirmOutcome } from "./stripe.js";
|
|
import { Store, type StorageLike } from "./store.js";
|
|
import { STEPS } from "./domain.js";
|
|
import type { App, Method, OrderRecord, SessionRecord, Step, Theme } from "./domain.js";
|
|
import { EMBED_READY, HEIGHT_MESSAGE, NAV_MESSAGE, THEME_MESSAGE, bgFromMessage, isNewPurchaseMessage, returnUrlFromMessage, routeFromMessage, themeFromMessage, trustedHost } from "./embed.js";
|
|
|
|
const app = document.getElementById("app");
|
|
if (app === null) throw new Error("main: #app is missing from the shell");
|
|
const root = app;
|
|
|
|
const pageFetch: typeof fetch = (input, init) => window.fetch(input, init);
|
|
|
|
const chromeSlot = document.getElementById("chrome");
|
|
if (chromeSlot === null) throw new Error("main: #chrome is missing from the shell");
|
|
|
|
function pageStore(): Store {
|
|
const probe = "sb.probe";
|
|
let real: StorageLike | undefined;
|
|
try {
|
|
const s = window.localStorage;
|
|
real = s;
|
|
s.setItem(probe, "1");
|
|
const durable = s.getItem(probe) === "1";
|
|
if (durable) {
|
|
s.removeItem(probe);
|
|
return new Store(s, true);
|
|
}
|
|
} catch {}
|
|
try { real?.removeItem(probe); } catch {}
|
|
return new Store(memoryOver(real), false);
|
|
}
|
|
|
|
function memoryOver(real: StorageLike | undefined): StorageLike {
|
|
const m = new Map<string, string>();
|
|
return {
|
|
getItem: (k) => {
|
|
const held = m.get(k);
|
|
if (held !== undefined) return held;
|
|
try { return real?.getItem(k) ?? null; } catch { return null; }
|
|
},
|
|
setItem: (k, v) => { m.set(k, v); },
|
|
removeItem: (k) => {
|
|
m.delete(k);
|
|
try { real?.removeItem(k); } catch {}
|
|
},
|
|
};
|
|
}
|
|
|
|
const store = pageStore();
|
|
|
|
const flow = new Flow({
|
|
store,
|
|
fetch: pageFetch,
|
|
sleep: api.realSleep,
|
|
now: Date.now,
|
|
newCode: generate,
|
|
hashCode: hash,
|
|
render: (view) => { paint(view); },
|
|
});
|
|
|
|
const embedded = window.self !== window.top;
|
|
|
|
const chromeUi = screens.chrome({
|
|
onNewPurchase: startPurchase,
|
|
onHistory: showCodes,
|
|
theme: store.theme(),
|
|
onTheme: (theme) => { setTheme(theme, true); },
|
|
onToggle: (open) => {
|
|
for (const node of [root, document.getElementById("contact")]) {
|
|
if (open) node?.setAttribute("inert", "");
|
|
else node?.removeAttribute("inert");
|
|
}
|
|
},
|
|
onHome: () => goToIndex(0),
|
|
});
|
|
if (embedded) document.documentElement.classList.add("embedded");
|
|
else chromeSlot.replaceChildren(chromeUi.node);
|
|
|
|
const THEME_ATTRIBUTE = "data-theme";
|
|
|
|
function applyTheme(theme: Theme): void {
|
|
const html = document.documentElement;
|
|
if (theme === "system") html?.removeAttribute(THEME_ATTRIBUTE);
|
|
else html?.setAttribute(THEME_ATTRIBUTE, theme);
|
|
// The site's navbar keys its dark palette on a `.dark` class, so mirror the resolved theme onto it while styles.css reads data-theme.
|
|
html?.classList.toggle("dark", theme === "dark" || (theme === "system" && prefersDark()));
|
|
chromeUi.showTheme(theme);
|
|
}
|
|
|
|
let hostOrigin: string | undefined;
|
|
|
|
let hostReturnUrl: string | undefined;
|
|
|
|
// echo is false for a theme that arrived from the host, so applying it does not post it straight back to the host.
|
|
function setTheme(theme: Theme, echo: boolean): void {
|
|
store.saveTheme(theme);
|
|
applyTheme(theme);
|
|
if (lastView?.screen === "cardForm") paint(lastView);
|
|
if (echo && embedded && hostOrigin !== undefined) {
|
|
window.parent.postMessage({ type: THEME_MESSAGE, theme }, hostOrigin);
|
|
}
|
|
}
|
|
|
|
let welcomeHeight = 0;
|
|
function measureWelcome(): void {
|
|
const landing = panels[0];
|
|
if (landing === undefined || root.firstChild !== track) return;
|
|
const pad = Number.parseFloat(getComputedStyle(document.body).paddingTop) || 0;
|
|
const measured = Math.ceil(landing.getBoundingClientRect().height + pad);
|
|
if (measured > 0) welcomeHeight = measured;
|
|
}
|
|
|
|
let lastHeight = 0;
|
|
function postHeight(): void {
|
|
measureWelcome();
|
|
// Rounding the fractional box height down leaves the iframe a sub-pixel short and the footer rides over the last row.
|
|
const height = Math.ceil(document.documentElement.getBoundingClientRect().height);
|
|
if (height === lastHeight) return;
|
|
lastHeight = height;
|
|
window.parent.postMessage({ type: HEIGHT_MESSAGE, height, min: welcomeHeight }, hostOrigin ?? "*");
|
|
}
|
|
|
|
function announceLocation(): void {
|
|
if (!embedded || hostOrigin === undefined) return;
|
|
window.parent.postMessage({ type: NAV_MESSAGE, hash: location.hash }, hostOrigin);
|
|
}
|
|
|
|
if (embedded) {
|
|
window.addEventListener("message", (event) => {
|
|
if (!trustedHost(event.origin)) return;
|
|
if (hostOrigin === undefined) { hostOrigin = event.origin; postHeight(); }
|
|
const theme = themeFromMessage(event.data);
|
|
if (theme !== undefined) { hostOrigin = event.origin; setTheme(theme, false); return; }
|
|
if (isNewPurchaseMessage(event.data)) { hostOrigin = event.origin; startPurchase(); return; }
|
|
const hash = routeFromMessage(event.data);
|
|
if (hash !== undefined) { hostOrigin = event.origin; applyRoute(hash); return; }
|
|
const returnUrl = returnUrlFromMessage(event.data);
|
|
if (returnUrl !== undefined) { hostOrigin = event.origin; hostReturnUrl = returnUrl; return; }
|
|
const bg = bgFromMessage(event.data);
|
|
if (bg !== undefined) { hostOrigin = event.origin; document.documentElement.style.setProperty("--bg", bg); }
|
|
});
|
|
new ResizeObserver(() => { postHeight(); }).observe(document.body);
|
|
try { window.parent.postMessage({ type: EMBED_READY, theme: store.theme() }, "*"); } catch {}
|
|
}
|
|
|
|
applyTheme(store.theme());
|
|
|
|
function prefersDark(): boolean {
|
|
return window.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
|
|
}
|
|
|
|
function cardAppearance(): ReturnType<typeof appearanceFor> {
|
|
return appearanceFor(store.theme(), prefersDark());
|
|
}
|
|
|
|
// A remembered return older than this is a stale attempt, not the buyer coming back from the redirect.
|
|
const CARD_RETURN_WINDOW_MS = 15 * 60 * 1000;
|
|
// Embedded, the redirect target must be the host page, or the confirm pulls the whole tab out of the site onto the standalone app.
|
|
function cardReturnUrl(): string {
|
|
return embedded && hostReturnUrl !== undefined ? hostReturnUrl : location.origin + location.pathname;
|
|
}
|
|
|
|
window.matchMedia?.("(prefers-color-scheme: dark)").addEventListener?.("change", () => {
|
|
if (store.theme() !== "system") return;
|
|
applyTheme("system");
|
|
if (lastView?.screen === "cardForm") paint(lastView);
|
|
});
|
|
|
|
function syncChrome(): void {
|
|
chromeUi.offerNewPurchase(store.newestOpen()?.submitted !== true);
|
|
}
|
|
|
|
window.addEventListener("keydown", (event) => {
|
|
if (!chromeUi.isOpen()) return;
|
|
const key = event.key;
|
|
if (key === "Escape") { chromeUi.close(); return; }
|
|
if (key !== "Tab") return;
|
|
const items = chromeUi.focusables();
|
|
if (items.length === 0) return;
|
|
const at = items.findIndex((item) => item === document.activeElement);
|
|
const back = event.shiftKey;
|
|
const next = at < 0 ? (back ? items.length - 1 : 0) : at + (back ? -1 : 1);
|
|
if (at >= 0 && next >= 0 && next < items.length) return;
|
|
event.preventDefault?.();
|
|
items[next < 0 ? items.length - 1 : next % items.length]?.focus?.();
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
const target = event.target;
|
|
if (target === null) return;
|
|
if (chromeUi.isOpen() && !chromeUi.holds(target)) chromeUi.close();
|
|
});
|
|
|
|
const TIER_LIMITS: Readonly<Record<string, { gb: number; days: number }>> = {
|
|
supporter: { gb: 2, days: 7 },
|
|
legend: { gb: 5, days: 21 },
|
|
};
|
|
|
|
function tierName(badgeType: string): string {
|
|
return badgeType.charAt(0).toUpperCase() + badgeType.slice(1);
|
|
}
|
|
|
|
function tierFeatures(badgeType: string): readonly string[] {
|
|
const limits = TIER_LIMITS[badgeType];
|
|
return limits === undefined ? [] : [`Files up to ${limits.gb} GB`, `Stored for ${limits.days} days`];
|
|
}
|
|
|
|
// The line under the duration screen's heading: what the chosen badge gives, since the tier cards are a screen back.
|
|
function tierSummary(badgeType: string): screens.ChosenTier | undefined {
|
|
const limits = TIER_LIMITS[badgeType];
|
|
return limits === undefined ? undefined
|
|
: { badge: `${tierName(badgeType)}:`, gives: `${limits.gb} GB files available for ${limits.days} days.` };
|
|
}
|
|
|
|
// The investment that earns the chosen badge as a perk: the tier's minimum until a term is chosen,
|
|
// then the exact amount for that term.
|
|
function investPerk(price: Price | undefined, months: number | undefined): screens.InvestPerk | undefined {
|
|
if (price === undefined) return undefined;
|
|
const exact = months === undefined ? undefined : perkAmount(price.badgeType, months);
|
|
return exact === undefined || months === undefined
|
|
? { badgeType: price.badgeType, amount: minimumFor(price.badgeType) }
|
|
: { badgeType: price.badgeType, amount: exact, months };
|
|
}
|
|
|
|
function withPerk<T extends object>(o: T, perk: screens.InvestPerk | undefined): T & { perk?: screens.InvestPerk } {
|
|
return perk === undefined ? o : { ...o, perk };
|
|
}
|
|
|
|
function priceOf(priceId: string | undefined): Price | undefined {
|
|
return CATALOG.prices.find((p) => p.priceId === priceId);
|
|
}
|
|
|
|
function chosenDuration(session: SessionRecord): string | undefined {
|
|
return session.offerId === undefined || session.offerId === "" ? undefined : session.offerId;
|
|
}
|
|
|
|
function offerOf(key: string | undefined): Offer | undefined {
|
|
return key === undefined || key === SINGLE_MONTH ? undefined : CATALOG.offers.find((o) => o.offerId === key);
|
|
}
|
|
|
|
function durationsFor(price: Price): Array<{ key: string; offer: Offer | undefined }> {
|
|
const offers = CATALOG.offers.filter((o) => o.priceId === price.priceId);
|
|
return [{ key: SINGLE_MONTH, offer: undefined }, ...offers.map((o) => ({ key: o.offerId, offer: o }))]
|
|
.sort((a, b) => (a.offer?.months ?? 1) - (b.offer?.months ?? 1));
|
|
}
|
|
|
|
function totalFor(price: Price | undefined, offer: Offer | undefined): Total | undefined {
|
|
if (price === undefined) return undefined;
|
|
const t = offerTotal(price.monthPrice, offer);
|
|
return typeof t === "string" ? undefined : t;
|
|
}
|
|
|
|
const HASHES: Readonly<Record<Step, string>> = { tier: "#/tier", months: "#/months", checkout: "#/checkout" };
|
|
const CODES_HASH = "#/codes";
|
|
|
|
const rail = screens.el("div", { class: "rail" });
|
|
const track = screens.el("div", { class: "track" }, rail);
|
|
const panels: HTMLElement[] = [];
|
|
let index = 0;
|
|
|
|
function landingIndex(): number {
|
|
const { route } = readHash(location.hash);
|
|
const fromHash = STEPS.findIndex((s) => HASHES[s] === route);
|
|
return fromHash >= 0 ? fromHash + 1 : 0;
|
|
}
|
|
|
|
const FIRST_INDEX_NEEDING_A_LEVEL = 2;
|
|
const CHECKOUT_INDEX = STEPS.length;
|
|
const PANEL_COUNT = STEPS.length + 1;
|
|
|
|
function reachableIndex(at: number): number {
|
|
return at >= FIRST_INDEX_NEEDING_A_LEVEL && effectiveSession().priceId === undefined ? 0 : at;
|
|
}
|
|
|
|
function hashForIndex(i: number): string {
|
|
const step = STEPS[i - 1];
|
|
return step === undefined ? "/" : HASHES[step];
|
|
}
|
|
|
|
let rateLimitStop: (() => void) | null = null;
|
|
let rateHoldStop: (() => void) | null = null;
|
|
|
|
function stopCountdowns(): void {
|
|
rateLimitStop?.();
|
|
rateLimitStop = null;
|
|
rateHoldStop?.();
|
|
rateHoldStop = null;
|
|
}
|
|
|
|
function replacePanel(at: number, node: HTMLElement): void {
|
|
if (at === CHECKOUT_INDEX) stopCountdowns();
|
|
const old = panels[at];
|
|
if (old === undefined) { panels[at] = node; return; }
|
|
rail.replaceChild(node, old);
|
|
panels[at] = node;
|
|
}
|
|
|
|
function rebuild(at: number): void {
|
|
replacePanel(at, buildPanel(at));
|
|
applyInert();
|
|
if (at === index) moveTrack(false);
|
|
}
|
|
|
|
// The order seeds the session from underneath, never over it, so a duration the buyer just picked is not overwritten by the old order.
|
|
function effectiveSession(): SessionRecord {
|
|
const session = store.session();
|
|
if (session.priceId !== undefined) return session;
|
|
const seed = selectionFromOrder(store.orders()[0], CATALOG.prices, CATALOG.offers);
|
|
return seed === undefined ? session : { ...seed, ...session };
|
|
}
|
|
|
|
function buildPanel(at: number): HTMLElement {
|
|
const session = effectiveSession();
|
|
switch (at) {
|
|
case 0:
|
|
return screens.landing({ onStart: () => goToIndex(1) });
|
|
case 1:
|
|
return screens.tiers({
|
|
tiers: CATALOG.prices.map((p) => ({
|
|
priceId: p.priceId,
|
|
badgeType: p.badgeType,
|
|
name: tierName(p.badgeType),
|
|
price: `${moneyCompact(p.monthPrice, p.currency)} / month`,
|
|
features: tierFeatures(p.badgeType),
|
|
disabled: totalFor(p, undefined) === undefined,
|
|
})),
|
|
...(session.priceId !== undefined ? { selected: session.priceId } : { selected: undefined }),
|
|
...withPerk({}, investPerk(priceOf(session.priceId), undefined)),
|
|
onSelect: (priceId) => {
|
|
unavailableMethod = undefined;
|
|
store.saveSession({ step: "tier", priceId, offerId: undefined });
|
|
rebuild(1);
|
|
rebuild(2);
|
|
rebuild(CHECKOUT_INDEX);
|
|
},
|
|
onContinue: () => goToIndex(2),
|
|
onBack: () => history.back(),
|
|
});
|
|
case 2: {
|
|
const price = priceOf(session.priceId);
|
|
const durations = price === undefined ? [] : durationsFor(price).map(({ key, offer }) => {
|
|
const total = totalFor(price, offer);
|
|
const months = offer?.months ?? 1;
|
|
const saving = total === undefined ? undefined : savingPercent(total.price, total.amount);
|
|
return {
|
|
key,
|
|
name: months === 1 ? "1 month" : `${months} months`,
|
|
...(total !== undefined ? { price: moneyCompact(total.amount, price.currency) } : {}),
|
|
...(total !== undefined && saving !== undefined && saving > 0
|
|
? { wasPrice: moneyCompact(total.price, price.currency) } : {}),
|
|
...(saving !== undefined && saving > 0 ? { savingPercent: saving } : {}),
|
|
disabled: total === undefined,
|
|
};
|
|
});
|
|
const chosenKey = chosenDuration(session);
|
|
const chosen = chosenKey === undefined ? undefined : totalFor(price, offerOf(chosenKey));
|
|
const tier = price === undefined ? undefined : tierSummary(price.badgeType);
|
|
return screens.durations({
|
|
durations,
|
|
selected: chosenKey,
|
|
...(tier !== undefined ? { tier } : {}),
|
|
...withPerk({}, investPerk(price, chosen?.months)),
|
|
onSelect: (key) => { store.saveSession({ step: "months", offerId: key }); rebuild(2); rebuild(CHECKOUT_INDEX); },
|
|
onContinue: () => goToIndex(CHECKOUT_INDEX),
|
|
onBack: () => history.back(),
|
|
});
|
|
}
|
|
default: {
|
|
const price = priceOf(session.priceId);
|
|
const offer = offerOf(chosenDuration(session));
|
|
const total = totalFor(price, offer);
|
|
const method = session.method ?? "xmr";
|
|
const open = store.newestOpen();
|
|
return screens.orderSummary({
|
|
canKeepTheCode: store.canHoldACode(),
|
|
badgeType: price?.badgeType ?? "",
|
|
months: total?.months ?? 1,
|
|
total: total === undefined ? "" : money(total.amount, price?.currency ?? "usd"),
|
|
...(total !== undefined && total.price > total.amount
|
|
? { discount: {
|
|
price: money(total.price, price?.currency ?? "usd"),
|
|
off: money(total.price - total.amount, price?.currency ?? "usd"),
|
|
...(savingPercent(total.price, total.amount) > 0 ? { percent: savingPercent(total.price, total.amount) } : {}),
|
|
} }
|
|
: {}),
|
|
selected: method,
|
|
...withPerk({}, investPerk(price, total?.months)),
|
|
...(unavailableMethod !== undefined ? { unavailable: unavailableMethod } : {}),
|
|
...(open !== undefined
|
|
? { openOrder: {
|
|
orderId: open.orderId,
|
|
...(open.submitted === true ? { awaitingCard: true } : {}),
|
|
onOpen: goToOrder,
|
|
} }
|
|
: {}),
|
|
onSelect: (m) => { store.saveSession({ step: "checkout", method: m }); rebuild(CHECKOUT_INDEX); },
|
|
onPay: () => { void pay(); },
|
|
onBack: () => history.back(),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
function applyInert(): void {
|
|
panels.forEach((p, i) => {
|
|
if (i === index) p.removeAttribute("inert");
|
|
else p.setAttribute("inert", "");
|
|
});
|
|
}
|
|
|
|
function reducedMotion(): boolean {
|
|
return window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
|
}
|
|
|
|
const SLIDE_MS = 320;
|
|
|
|
function panelHeight(at: number): number | null {
|
|
const box = panels[at]?.getBoundingClientRect?.();
|
|
const height = box === undefined ? 0 : Math.ceil(box.height);
|
|
return height > 0 ? height : null;
|
|
}
|
|
|
|
function moveTrack(smooth: boolean): void {
|
|
const slide = `--slide:${smooth && !reducedMotion() ? SLIDE_MS : 0}ms`;
|
|
rail.setAttribute("style", `${slide};transform:translateX(-${index * 100}%)`);
|
|
const height = panelHeight(index);
|
|
track.setAttribute("style", height === null ? slide : `${slide};height:${height}px`);
|
|
}
|
|
|
|
function goToIndex(at: number): void {
|
|
const step = STEPS[at - 1];
|
|
if (step !== undefined) store.saveSession({ step });
|
|
history.pushState(null, "", hashForIndex(at));
|
|
showIndex(at, true);
|
|
announceLocation();
|
|
}
|
|
|
|
function showIndex(at: number, smooth: boolean): void {
|
|
if (at === 0) unavailableMethod = undefined;
|
|
lastView = null;
|
|
releaseCardElement();
|
|
const freshMount = panels.length === 0 || root.firstChild !== track;
|
|
if (panels.length === 0) {
|
|
for (let i = 0; i < PANEL_COUNT; i++) panels.push(buildPanel(i));
|
|
rail.replaceChildren(...panels);
|
|
} else {
|
|
replacePanel(at, buildPanel(at));
|
|
}
|
|
if (root.firstChild !== track) root.replaceChildren(track);
|
|
index = at;
|
|
applyInert();
|
|
syncChrome();
|
|
moveTrack(smooth && !freshMount);
|
|
}
|
|
|
|
let unavailableMethod: Method | undefined;
|
|
|
|
let checkoutInFlight = false;
|
|
|
|
async function pay(): Promise<void> {
|
|
if (checkoutInFlight) return;
|
|
const session = effectiveSession();
|
|
const price = priceOf(session.priceId);
|
|
if (price === undefined) return;
|
|
if (store.newestOpen()?.submitted === true) return;
|
|
const duration = chosenDuration(session);
|
|
const sel: Selection = {
|
|
priceId: price.priceId,
|
|
...(duration !== undefined && duration !== SINGLE_MONTH ? { offerId: duration } : {}),
|
|
method: session.method ?? "xmr",
|
|
};
|
|
const payButton = panels[CHECKOUT_INDEX]?.querySelector("button.primary");
|
|
if (payButton === null || payButton === undefined || payButton.hasAttribute("disabled")) return;
|
|
payButton.setAttribute("disabled", "");
|
|
|
|
// The disabled attribute does not survive a repaint, so this flag stops a second checkout buying a second invoice.
|
|
checkoutInFlight = true;
|
|
const since = flow.epoch;
|
|
let outcome: CheckoutOutcome;
|
|
try {
|
|
outcome = await flow.checkout(sel, session.app);
|
|
} finally {
|
|
checkoutInFlight = false;
|
|
}
|
|
if (flow.epoch !== since) return;
|
|
switch (outcome.kind) {
|
|
case "created": {
|
|
store.clearSession();
|
|
history.replaceState(null, "", `?order=${encodeURIComponent(outcome.order.orderId)}`);
|
|
announceLocation();
|
|
unavailableMethod = undefined;
|
|
root.replaceChildren(screens.loading());
|
|
flow.watch(outcome.order.orderId, {
|
|
initial: { status: "open", ...outcome.invoice },
|
|
method: outcome.method,
|
|
record: outcome.order,
|
|
});
|
|
return;
|
|
}
|
|
case "catalogChanged":
|
|
root.replaceChildren(screens.catalogChanged(() => {
|
|
newInvoice(session.app);
|
|
}));
|
|
return;
|
|
case "rateLimited": {
|
|
const screen = screens.rateLimited(
|
|
{ total: payTotal(), method: sel.method, seconds: outcome.retryAfter, onBack: () => history.back() },
|
|
() => { if (index === CHECKOUT_INDEX && root.firstChild === track) rebuild(CHECKOUT_INDEX); },
|
|
);
|
|
replacePanel(CHECKOUT_INDEX, screen.node);
|
|
rateLimitStop = screen.stop;
|
|
applyInert();
|
|
return;
|
|
}
|
|
case "providerUnavailable":
|
|
unavailableMethod = outcome.method;
|
|
store.saveSession({ method: firstAvailable(outcome.method) });
|
|
rebuild(CHECKOUT_INDEX);
|
|
return;
|
|
case "failed":
|
|
replacePanel(CHECKOUT_INDEX, screens.invoiceFailure({
|
|
...withPerk({}, investPerk(price, totalFor(price, offerOf(duration))?.months)),
|
|
onBack: () => { rebuild(CHECKOUT_INDEX); },
|
|
onRetry: () => { rebuild(CHECKOUT_INDEX); void pay(); },
|
|
}));
|
|
applyInert();
|
|
moveTrack(false);
|
|
return;
|
|
}
|
|
}
|
|
|
|
function payTotal(): string {
|
|
const session = effectiveSession();
|
|
const price = priceOf(session.priceId);
|
|
const total = totalFor(price, offerOf(chosenDuration(session)));
|
|
return total === undefined ? "" : money(total.amount, price?.currency ?? "usd");
|
|
}
|
|
|
|
// A coin that is down falls back to the other coin, and the card to the first coin: a buyer who
|
|
// chose a coin is not moved to a card.
|
|
function firstAvailable(down: Method): Method {
|
|
return screens.METHOD_ORDER.find((m) => m !== "card" && m !== down) ?? "card";
|
|
}
|
|
|
|
let cancelNotice: { orderId: string; epoch: number; message: string } | undefined;
|
|
|
|
// The watch was stopped before the request, so restart it or the page sits on a screen nothing updates.
|
|
function refuseCancel(orderId: string, resume: Parameters<typeof flow.watch>[1], message: string): never {
|
|
flow.watch(orderId, resume);
|
|
throw new screens.CancelRefused(message);
|
|
}
|
|
|
|
let cancelInFlight = false;
|
|
|
|
async function cancelInvoice(orderId: string): Promise<void> {
|
|
if (!window.confirm(screens.CANCEL_CONFIRM)) return;
|
|
cancelNotice = undefined;
|
|
const { record: _record, ...resume } = flow.liveWatches().find((w) => w.orderId === orderId)?.restartOptions() ?? {};
|
|
flow.stopAll();
|
|
const since = flow.epoch;
|
|
let cancelled: api.InvoiceView | undefined;
|
|
try {
|
|
cancelled = await api.cancelInvoice(orderId, pageFetch);
|
|
if (store.order(orderId) !== undefined) applyView(store, orderId, cancelled, Date.now());
|
|
if (flow.epoch !== since) {
|
|
const live = flow.liveWatches().find((w) => w.orderId === orderId);
|
|
if (live !== undefined) {
|
|
// flow.watch hands back the existing unfinished loop, so the stopped loop must be awaited before restarting.
|
|
live.stop();
|
|
await live.done;
|
|
flow.watch(orderId, { initial: cancelled });
|
|
}
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
const code = e instanceof api.ApiError ? e.code : undefined;
|
|
if (code === "not_open") {
|
|
const held = store.order(orderId);
|
|
if (held !== undefined) store.saveOrder(withoutDestination(held));
|
|
}
|
|
if (flow.epoch !== since) return;
|
|
if (code === "not_open") refuseCancel(orderId, resume, screens.CANCEL_NOT_OPEN);
|
|
cancelNotice = {
|
|
orderId,
|
|
epoch: flow.epoch,
|
|
message: code === "funded" ? screens.CANCEL_HAS_FUNDS : screens.CANCEL_FAILED,
|
|
};
|
|
if (code === "funded") refuseCancel(orderId, resume, screens.CANCEL_HAS_FUNDS);
|
|
flow.watch(orderId, resume);
|
|
throw e;
|
|
}
|
|
if (cancelled.status === "open") {
|
|
cancelNotice = { orderId, epoch: flow.epoch, message: screens.CANCEL_STILL_OPEN };
|
|
refuseCancel(orderId, resume, screens.CANCEL_STILL_OPEN);
|
|
}
|
|
if (cancelled.paidInFull === true || cancelled.cryptoAmountPaid !== undefined) {
|
|
flow.watch(orderId, resume);
|
|
return;
|
|
}
|
|
store.markCanceled(orderId);
|
|
newInvoice(store.order(orderId)?.app);
|
|
}
|
|
|
|
// The Cancel button's disabled attribute does not survive a repaint, so this flag allows one cancel at a time.
|
|
async function cancelInvoiceOnce(orderId: string): Promise<void> {
|
|
if (cancelInFlight) return;
|
|
cancelInFlight = true;
|
|
try {
|
|
await cancelInvoice(orderId);
|
|
} finally {
|
|
cancelInFlight = false;
|
|
}
|
|
}
|
|
|
|
function resetToLanding(nav: "push" | "replace"): void {
|
|
stopCountdowns();
|
|
flow.stopAll();
|
|
store.clearCardReturn();
|
|
if (nav === "push") history.pushState(null, "", "/");
|
|
else history.replaceState(null, "", "/");
|
|
panels.length = 0;
|
|
showIndex(0, false);
|
|
announceLocation();
|
|
}
|
|
|
|
// A purchase started over keeps the app that opened the page, or its order would end by showing the code.
|
|
function newInvoice(openedBy: App | undefined): void {
|
|
store.clearSession();
|
|
if (openedBy !== undefined) store.saveSession({ app: openedBy });
|
|
resetToLanding("replace");
|
|
}
|
|
|
|
function startPurchase(): void {
|
|
stopCountdowns();
|
|
flow.stopAll();
|
|
store.clearCardReturn();
|
|
history.replaceState(null, "", "/");
|
|
history.pushState(null, "", hashForIndex(1));
|
|
panels.length = 0;
|
|
showIndex(1, false);
|
|
announceLocation();
|
|
}
|
|
|
|
let lastView: PaymentView | null = null;
|
|
|
|
function isOffline(): boolean {
|
|
return navigator.onLine === false;
|
|
}
|
|
|
|
function paint(view: PaymentView): void {
|
|
if (view.screen === "cardForm" && cardConfirmPending) {
|
|
lastView = view;
|
|
return;
|
|
}
|
|
stopCountdowns();
|
|
releaseCardElement();
|
|
lastView = view;
|
|
syncChrome();
|
|
switch (view.screen) {
|
|
case "unknownOrder":
|
|
root.replaceChildren(screens.unknownOrder(() => { newInvoice(store.session().app); }));
|
|
return;
|
|
case "codeIssued":
|
|
root.replaceChildren(codeScreen(view));
|
|
return;
|
|
case "paidNoCode":
|
|
root.replaceChildren(screens.paidNoCode({ order: view.order, settledAt: view.invoice?.settledAt }));
|
|
return;
|
|
case "awaitingPayment": {
|
|
const awaitingPayment = screens.awaitingPayment({
|
|
order: view.order, invoice: view.invoice, method: view.method,
|
|
nowMs: Date.now(), resumed: view.resumed, offline: isOffline(),
|
|
onCancel: () => cancelInvoiceOnce(view.order.orderId),
|
|
...(cancelNotice?.orderId === view.order.orderId && cancelNotice.epoch === flow.epoch
|
|
? { notice: cancelNotice.message } : {}),
|
|
});
|
|
rateHoldStop = awaitingPayment.stop;
|
|
root.replaceChildren(awaitingPayment.node);
|
|
return;
|
|
}
|
|
case "awaitingConfirmation":
|
|
root.replaceChildren(screens.awaitingConfirmation({
|
|
order: view.order, invoice: view.invoice, method: view.method, gaveUp: view.gaveUp,
|
|
offline: isOffline() && !view.gaveUp,
|
|
onCheckAgain: () => checkAgain(view.order.orderId),
|
|
}));
|
|
return;
|
|
case "windowClosed":
|
|
root.replaceChildren(screens.windowClosed({
|
|
order: view.order, invoice: view.invoice, offline: isOffline(),
|
|
canceled: store.order(view.order.orderId)?.canceled === true,
|
|
onNewInvoice: () => { newInvoice(view.order.app); },
|
|
}));
|
|
return;
|
|
case "cardForm":
|
|
renderCardForm(view);
|
|
return;
|
|
case "detailsUnavailable":
|
|
root.replaceChildren(screens.detailsUnavailable({
|
|
order: view.order,
|
|
onCheckAgain: () => checkAgain(view.order.orderId), onNewInvoice: () => { newInvoice(view.order.app); },
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
|
|
type CodeView = Extract<PaymentView, { screen: "codeIssued" }>;
|
|
|
|
const codeRevealed = new Set<string>();
|
|
const appLaunched = new Set<string>();
|
|
|
|
function codeScreen(view: CodeView): HTMLElement {
|
|
const { orderId } = view.order;
|
|
// An unsaved code exists only on this screen, so it is never held back behind a link that may fail.
|
|
if (view.order.app !== "mobile" || !view.savedLocally) {
|
|
return screens.codeIssued({ code: view.code, savedLocally: view.savedLocally, app: view.order.app });
|
|
}
|
|
const link = appCodeLink(view.code);
|
|
const ending = screens.returnToApp({
|
|
onReturn: () => { location.href = link; },
|
|
code: codeRevealed.has(orderId)
|
|
? { kind: "shown", code: view.code, savedLocally: view.savedLocally }
|
|
: { kind: "hidden", onShow: () => { codeRevealed.add(orderId); paint(view); } },
|
|
});
|
|
// A connectivity event repaints this screen, and each repaint must not fire the scheme again.
|
|
if (!appLaunched.has(orderId)) {
|
|
appLaunched.add(orderId);
|
|
ending.append(screens.appLauncher(link));
|
|
}
|
|
return ending;
|
|
}
|
|
|
|
let cardElementDestroy: (() => void) | null = null;
|
|
|
|
let cardConfirmPending = false;
|
|
|
|
function releaseCardElement(): void {
|
|
const destroy = cardElementDestroy;
|
|
cardElementDestroy = null;
|
|
destroy?.();
|
|
}
|
|
|
|
type CardView = Extract<PaymentView, { screen: "cardForm" }>;
|
|
|
|
// A repaint would build a fresh Element and re-enable Pay while the previous confirm is in flight, and checkout has no idempotency key.
|
|
function renderCardForm(view: CardView): void {
|
|
releaseCardElement();
|
|
const plan = cardPlan(publishableKey(), isOffline());
|
|
if (plan.kind === "unavailable") {
|
|
root.replaceChildren(cardFailureScreen(view, plan.reason));
|
|
return;
|
|
}
|
|
const shell = (body: HTMLElement): HTMLElement => screens.cardForm({
|
|
order: view.order, invoice: view.invoice, resumed: view.resumed, body,
|
|
// A PaymentIntent is only cancelable before it confirms, so the button is inert once a confirm is in flight.
|
|
onCancel: () => (cardConfirmPending ? Promise.resolve() : cancelInvoiceOnce(view.order.orderId)),
|
|
...(cancelNotice?.orderId === view.order.orderId && cancelNotice.epoch === flow.epoch
|
|
? { notice: cancelNotice.message } : {}),
|
|
});
|
|
const mount = screens.cardMount();
|
|
let confirm: (() => Promise<ConfirmOutcome>) | null = null;
|
|
const fields = screens.cardFields({
|
|
mount,
|
|
total: screens.invoiceTotal(view.invoice),
|
|
onPay: () => {
|
|
if (confirm === null) return;
|
|
fields.busy(true);
|
|
cardConfirmPending = true;
|
|
// The confirm redirects the page, so remember the return before calling it.
|
|
store.rememberCardReturn(view.order.orderId, Date.now());
|
|
void confirm().then((outcome) => {
|
|
cardConfirmPending = false;
|
|
if (outcome.kind === "submitted") { cardConfirmed(view, node); return; }
|
|
store.clearCardReturn();
|
|
if (root.firstChild !== node) {
|
|
if (lastView !== null) paint(lastView);
|
|
return;
|
|
}
|
|
fields.busy(false);
|
|
fields.error(outcome.message);
|
|
});
|
|
},
|
|
});
|
|
const node = shell(fields.node);
|
|
root.replaceChildren(node);
|
|
void mountCard({ plan, clientSecret: view.clientSecret, target: mount, appearance: cardAppearance(), returnUrl: cardReturnUrl(), loadStripe: loadStripeJs })
|
|
.then((result) => {
|
|
if (root.firstChild !== node) {
|
|
if (result.kind === "mounted") result.destroy();
|
|
return;
|
|
}
|
|
if (result.kind === "failed") {
|
|
root.replaceChildren(cardFailureScreen(view, result.reason));
|
|
return;
|
|
}
|
|
confirm = result.confirm;
|
|
cardElementDestroy = result.destroy;
|
|
fields.enable();
|
|
});
|
|
}
|
|
|
|
function cardFailureScreen(view: CardView, reason: CardFailure): HTMLElement {
|
|
return screens.cardUnavailable({
|
|
order: view.order, reason,
|
|
onRetry: () => { renderCardForm(view); },
|
|
onNewInvoice: () => { newInvoice(view.order.app); },
|
|
});
|
|
}
|
|
|
|
function cardConfirmed(view: CardView, owner: Node): void {
|
|
store.markSubmitted(view.order.orderId);
|
|
if (root.firstChild !== owner) return;
|
|
const live = flow.liveWatches().find((w) => w.orderId === view.order.orderId)?.restartOptions().record;
|
|
const confirmed: OrderRecord = { ...(store.order(view.order.orderId) ?? live ?? view.order), submitted: true };
|
|
flow.stopAll();
|
|
paint({ screen: "awaitingConfirmation", order: view.order, invoice: undefined, gaveUp: false, method: "card" });
|
|
flow.watch(view.order.orderId, { record: confirmed, method: "card" });
|
|
}
|
|
|
|
function goToOrder(orderId: string): void {
|
|
history.pushState(null, "", `?order=${encodeURIComponent(orderId)}`);
|
|
openOrder(orderId);
|
|
announceLocation();
|
|
}
|
|
|
|
function checkAgain(orderId: string): void {
|
|
if (flow.checkAgain(orderId) === null) {
|
|
if (lastView !== null) paint(lastView);
|
|
return;
|
|
}
|
|
root.replaceChildren(screens.loading());
|
|
}
|
|
|
|
function openOrder(orderId: string): void {
|
|
flow.stopAll();
|
|
// A connectivity event repaints lastView, which would draw the order just left under this order's URL.
|
|
lastView = null;
|
|
releaseCardElement();
|
|
root.replaceChildren(screens.loading());
|
|
flow.watch(orderId, { resumed: true });
|
|
}
|
|
|
|
function showCodes(): void {
|
|
flow.stopAll();
|
|
stopCountdowns();
|
|
// A bare hash resolves against the current URL and keeps the ?order= this was opened from, so push the full path.
|
|
if (location.hash !== CODES_HASH) history.pushState(null, "", location.pathname + CODES_HASH);
|
|
renderCodes(store.orders());
|
|
void flow.refreshHistory().then(renderCodes);
|
|
announceLocation();
|
|
}
|
|
|
|
function renderCodes(entries: readonly OrderRecord[]): void {
|
|
if (location.hash !== CODES_HASH) return;
|
|
lastView = null;
|
|
releaseCardElement();
|
|
syncChrome();
|
|
root.replaceChildren(screens.purchaseHistory({
|
|
rows: historyRows(entries),
|
|
keepsNewCodes: store.durable,
|
|
onOpen: goToOrder,
|
|
onStart: startPurchase,
|
|
onForget: () => {
|
|
if (!window.confirm("Remove every code stored in this browser? This cannot be undone.")) return;
|
|
store.forgetEverything();
|
|
resetToLanding("replace");
|
|
},
|
|
}));
|
|
}
|
|
|
|
function syncFromLocation(fresh: boolean): void {
|
|
flow.stopAll();
|
|
stopCountdowns();
|
|
chromeUi.close();
|
|
const params = new URLSearchParams(location.search);
|
|
const orderId = params.get("order");
|
|
if (orderId === null && location.hash === CODES_HASH) {
|
|
renderCodes(store.orders());
|
|
void flow.refreshHistory().then(renderCodes);
|
|
announceLocation();
|
|
return;
|
|
}
|
|
if (orderId === null && fresh) {
|
|
const resumeId = store.takeCardReturn(CARD_RETURN_WINDOW_MS, Date.now());
|
|
if (resumeId !== undefined) {
|
|
history.replaceState(null, "", `?order=${encodeURIComponent(resumeId)}`);
|
|
openOrder(resumeId);
|
|
announceLocation();
|
|
return;
|
|
}
|
|
}
|
|
const load = resolveLoad({ search: location.search }, fresh ? store.newestOpen() : undefined);
|
|
if (load.kind === "order") {
|
|
openOrder(load.orderId);
|
|
announceLocation();
|
|
return;
|
|
}
|
|
const at = reachableIndex(landingIndex());
|
|
if (fresh && at > 0) {
|
|
// The steps beneath a deep-linked one are not in history, so rebuild the stack or Back leaves the wizard.
|
|
history.replaceState(null, "", "/");
|
|
for (let i = 1; i <= at; i++) history.pushState(null, "", hashForIndex(i));
|
|
} else {
|
|
const want = hashForIndex(at);
|
|
if (want === "/" ? location.hash !== "" : location.hash !== want) {
|
|
history.replaceState(null, "", want === "/" ? location.pathname : want);
|
|
}
|
|
}
|
|
showIndex(at, root.firstChild === track && panels.length > 0);
|
|
announceLocation();
|
|
}
|
|
|
|
function applyRoute(hash: string): void {
|
|
const path = location.pathname;
|
|
history.pushState(null, "", hash === "" || hash === "/" ? path : path + hash);
|
|
syncFromLocation(false);
|
|
}
|
|
|
|
window.addEventListener("popstate", () => { syncFromLocation(false); });
|
|
|
|
window.addEventListener("resize", () => {
|
|
if (root.firstChild === track) moveTrack(false);
|
|
});
|
|
|
|
document.addEventListener("visibilitychange", () => {
|
|
for (const w of flow.liveWatches()) {
|
|
if (document.hidden) w.suspend();
|
|
else w.resume();
|
|
}
|
|
});
|
|
|
|
function repaintConnectivity(): void {
|
|
if (lastView !== null) paint(lastView);
|
|
}
|
|
|
|
window.addEventListener("online", repaintConnectivity);
|
|
window.addEventListener("offline", repaintConnectivity);
|
|
|
|
// Anubis serves its challenge as HTML at the page's path, so a worker registered before the shell is on screen could cache the challenge as the shell.
|
|
function registerServiceWorker(): void {
|
|
if (!("serviceWorker" in navigator)) return;
|
|
void navigator.serviceWorker.register("/sw.js").catch(() => {});
|
|
}
|
|
|
|
// The shell prerenders the welcome page, so the document is that page until the first render, and a load resolving to a codes or order screen never mounts the wizard for measureWelcome to read.
|
|
if (embedded && welcomeHeight <= 0) welcomeHeight = Math.ceil(document.documentElement.getBoundingClientRect().height);
|
|
|
|
// Read before the first sync rewrites the hash, which drops the flag from the URL.
|
|
const entryApp = readHash(location.hash).app;
|
|
if (entryApp !== undefined) store.saveSession({ app: entryApp });
|
|
|
|
syncFromLocation(true);
|
|
document.documentElement.classList.remove("sb-booting");
|
|
registerServiceWorker();
|