stackchain-dashboard/frontend/mobile-device-setup.js
timmy 7f8ee771dc
All checks were successful
CI / lint (pull_request) Successful in 1m16s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped
feat: surface mobile device readiness (Closes #559)
2026-08-11 09:33:27 +00:00

127 lines
5.3 KiB
JavaScript

(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory;
else root.createMobileDeviceSetup = factory;
})(typeof self !== 'undefined' ? self : this, function createMobileDeviceSetup(options) {
const promptDismissKey = 'stackchain.device-setup-prompt-dismissed-until';
const promptDismissMs = 7 * 24 * 60 * 60 * 1000;
const steps = [
['install', options.installButton, options.installStatus, options.install],
['offline', options.offlineButton, options.offlineStatus, options.enableOffline],
['push', options.pushButton, options.pushStatus, options.enablePush],
];
let trigger = options.launcher;
function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
const complete = steps.filter(([name]) => readiness[name].state === 'complete').length;
return {available, complete};
}
function renderPrompt(readiness) {
if (!options.promptCard) return;
const {available, complete} = readinessCounts(readiness);
let dismissed = false;
try {
dismissed = Number(options.promptStorage?.getItem(promptDismissKey) || 0) > (options.now?.() ?? Date.now());
} catch (_error) {}
options.promptSummary.textContent = `${complete} of ${available} steps complete`;
options.promptCard.hidden = dismissed || available === 0 || complete === available;
}
async function render() {
const readiness = await options.getReadiness();
let available = 0;
let complete = 0;
steps.forEach(([name, button, status]) => {
const step = readiness[name];
status.textContent = step.detail;
button.hidden = step.state === 'complete' || step.state === 'unavailable';
button.disabled = step.state === 'pending';
if (step.state !== 'unavailable') available += 1;
if (step.state === 'complete') complete += 1;
});
options.readyStatus.textContent = complete === available
? 'This device is ready.'
: `${complete} of ${available} available steps ready.`;
renderPrompt(readiness);
return readiness;
}
async function open(event) {
trigger = event?.currentTarget || options.launcher;
await render();
options.sheet.hidden = false;
options.closeButton.focus();
}
function close() {
options.sheet.hidden = true;
trigger?.focus?.();
}
async function start() {
options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open);
options.promptDismiss?.addEventListener('click', () => {
try {
options.promptStorage?.setItem(promptDismissKey, String((options.now?.() ?? Date.now()) + promptDismissMs));
} catch (_error) {}
options.promptCard.hidden = true;
});
options.closeButton.addEventListener('click', close);
options.sheet.addEventListener('click', event => {
if (event.target === options.sheet) close();
});
options.escapeTarget.addEventListener('keydown', event => {
if (event.key === 'Escape' && !options.sheet.hidden) close();
});
steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => {
button.disabled = true;
try { await action(); }
finally { await render(); }
});
});
renderPrompt(await options.getReadiness());
}
return {start, open, close, render};
});
if (typeof module === 'object' && module.exports) {
module.exports.mount = mountMobileDeviceSetup;
} else {
self.createMobileDeviceSetup.mount = mountMobileDeviceSetup;
}
function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector);
return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
offlineButton:qs('#device-setup-offline'), pushButton:qs('#device-setup-push'),
installStatus:qs('#device-setup-install-status'), offlineStatus:qs('#device-setup-offline-status'),
pushStatus:qs('#device-setup-push-status'), readyStatus:qs('#device-setup-ready-status'),
promptCard:qs('#device-readiness-card'), promptSummary:qs('#device-readiness-summary'),
promptLauncher:qs('#finish-device-setup'), promptDismiss:qs('#dismiss-device-readiness'),
promptStorage:options.promptStorage,
escapeTarget:options.document,
getReadiness:() => ({
install:options.installApp.state(),
offline:!options.offlineAvailable()
? {state:'unavailable', detail:'Offline saving is unavailable in this browser.'}
: options.offlineEnabled()
? {state:'complete', detail:qs('#offline-work-status').textContent || 'Offline work is saved.'}
: {state:'incomplete', detail:'Private My Work and Today data are not saved offline.'},
push:qs('#push-updates').disabled
? {state:'unavailable', detail:qs('#push-update-status').textContent || 'Update notifications are unavailable.'}
: qs('#push-updates').checked
? {state:'complete', detail:'New update notifications are enabled.'}
: {state:'incomplete', detail:qs('#push-update-status').textContent || 'New update notifications are off.'},
}),
install:() => options.installApp.install(),
enableOffline:options.enableOffline,
enablePush:options.enablePush,
});
}