Stabilize push registration flow

This commit is contained in:
Dorian
2026-05-15 18:12:04 -05:00
parent 940ee6a590
commit 1ad460f8c2
6 changed files with 93 additions and 107 deletions

View File

@@ -6,7 +6,6 @@ const APP_SHELL = [
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(APP_SHELL)))
self.skipWaiting()
})
self.addEventListener('activate', (event) => {
@@ -62,7 +61,7 @@ self.addEventListener('pushsubscriptionchange', (event) => {
await fetch('/api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ subscription }),
body: JSON.stringify({ subscription: subscription.toJSON() }),
})
})())
})

View File

@@ -470,7 +470,10 @@ const serveStatic = (req, res) => {
'.webmanifest': 'application/manifest+json',
}
const headers = { 'Content-Type': types[ext] || 'application/octet-stream' }
if (path.basename(safePath) === 'sw.js' || ext === '.html' || ext === '.webmanifest') {
if (path.basename(safePath) === 'sw.js') {
headers['Service-Worker-Allowed'] = '/'
headers['Cache-Control'] = 'no-store'
} else if (ext === '.html' || ext === '.webmanifest') {
headers['Cache-Control'] = 'no-store'
} else if (safePath.startsWith(path.join(distDir, 'assets'))) {
headers['Cache-Control'] = 'public, max-age=31536000, immutable'

View File

@@ -119,18 +119,11 @@ const MEMBERSHIP_ID_PATTERN = /^L484-\d{4}-[A-Z0-9]{6}$/
const DATA_IMAGE_PATTERN = /^data:image\/png;base64,[A-Za-z0-9+/=]+$/
const NPUB_PATTERN = /^npub1[023456789acdefghjklmnpqrstuvwxyz]+$/
const covenantItems = [
'I am joining a private, members-only association, not a public business.',
"Participation in L484's activities, meals, and services is limited to accepted members only.",
'All exchanges of goods or services within L484 are private & internal.',
'Suggested contribution amounts communicated by L484 are internal coordination references only, not public prices.',
'Participation is voluntary and undertaken at my own risk within a private context.',
'Membership is granted for a 24-hour period beginning upon acceptance of this application, unless ended earlier by the member or by L484.',
"Any disputes will be resolved privately and consistent with L484's ecclesiastical and contractual principles contained in this covenant.",
'I acknowledge that my membership, participation, and personal information are confidential, and that I will respect the privacy of L484 and other members.',
'I acknowledge that L484 operates under internal Articles, Bylaws, and policies, which are not publicly distributed, and that I do not acquire governance, voting, or inspection rights by virtue of my membership.',
'I acknowledge and accept that I may have interacted with L484 or its activities prior to completing this application, and I agree that such interactions are part of my voluntary participation as a member.',
"I agree to abide by L484's rules, governance, and confidentiality requirements, as outlined in this Covenant.",
'I agree not to represent L484 or its activities as open to the public or as a commercial entity.',
'I am joining L484 as a private, members-only association.',
"L484 activities, meals, and services are limited to accepted members and handled privately.",
'Suggested contributions are internal coordination references, not public prices.',
'Participation is voluntary, confidential, and at my own risk within a private context.',
"I agree to L484's rules, governance, privacy expectations, and private dispute process.",
]
const activeBackground = ref(0)
const loadedHeroBackgroundIndexes = ref(new Set([0]))
@@ -3542,10 +3535,6 @@ watch(mobileMenuOpen, (open) => {
<li v-for="item in covenantItems" :key="item">{{ item }}</li>
</ol>
</div>
<label class="flex items-center gap-3 text-sm leading-none text-white/75">
<input v-model="form.accepted" class="h-4 w-4 shrink-0 accent-amber-400" type="checkbox" />
<span>I have read and agree to the L484 Membership Covenant.</span>
</label>
</div>
<div v-if="signupStep === 4" class="space-y-4">
@@ -3642,7 +3631,10 @@ watch(mobileMenuOpen, (open) => {
</p>
</div>
<div class="modal-footer border-t border-white/10 p-5" :class="{ 'install-step-footer': signupStep === 1 }">
<div
class="modal-footer border-t border-white/10 p-5"
:class="{ 'install-step-footer': signupStep === 1, 'has-terms-check': signupStep === 3 }"
>
<template v-if="signupStep === 0">
<button class="primary-action" type="button" @click="startSignup">
Start signup
@@ -3671,10 +3663,14 @@ watch(mobileMenuOpen, (open) => {
</template>
<template v-else>
<label v-if="signupStep === 3" class="signup-terms-check">
<input v-model="form.accepted" type="checkbox" />
<span>I have read and agree to the L484 Membership Covenant.</span>
</label>
<button class="secondary-action" type="button" @click="previousStep">
Back
</button>
<button v-if="signupStep < 4" class="primary-action" type="button" @click="nextStep">
<button v-if="signupStep < 4" class="primary-action" type="button" :disabled="signupStep === 3 && !form.accepted" @click="nextStep">
Continue
</button>
<button v-else-if="signupStep === 4" class="primary-action" type="button" :disabled="isCreatingMembership" @click="createMembership">

View File

@@ -6,17 +6,8 @@ createApp(App).mount('#app')
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).then((registration) => {
registration.update().catch(() => {})
}).catch((error) => {
navigator.serviceWorker.register('/sw.js').catch((error) => {
console.warn('Service worker registration failed:', error)
})
})
let refreshing = false
navigator.serviceWorker.addEventListener('controllerchange', () => {
if (refreshing) return
refreshing = true
window.location.reload()
})
}

View File

@@ -2,7 +2,7 @@ import { ref } from 'vue'
const permission = ref(typeof Notification === 'undefined' ? 'unsupported' : Notification.permission)
const urlBase64ToArrayBuffer = (base64String) => {
const urlBase64ToUint8Array = (base64String) => {
const clean = String(base64String || '').trim().replace(/^["']|["']$/g, '')
const padding = '='.repeat((4 - (clean.length % 4)) % 4)
const base64 = (clean + padding).replace(/-/g, '+').replace(/_/g, '/')
@@ -12,39 +12,6 @@ const urlBase64ToArrayBuffer = (base64String) => {
return key
}
const isBraveBrowser = async () => {
try {
return Boolean(navigator.brave && await navigator.brave.isBrave())
} catch {
return false
}
}
const explainSubscribeError = async (error) => {
const message = error instanceof Error ? error.message : 'push service error'
const brave = await isBraveBrowser()
if (brave && /push service|registration failed|not available|permission/i.test(message)) {
return 'Brave still cannot reach its Web Push service. Fully quit Brave, reopen the installed L484 app, and try again. If it still fails, remove and reinstall the L484 app after enabling Brave push messaging.'
}
if (/push service|registration failed|not available/i.test(message)) {
return 'The browser push service is unavailable. Fully quit the browser, reopen the installed L484 app, and try again.'
}
return `Push registration failed: ${message}. Fully close and reopen the installed app, then try again.`
}
const repairServiceWorkerRegistration = async () => {
const registrations = await navigator.serviceWorker.getRegistrations()
await Promise.all(registrations.map((registration) => registration.unregister().catch(() => false)))
if ('caches' in window) {
const keys = await caches.keys()
await Promise.all(keys.filter((key) => key.startsWith('l484-')).map((key) => caches.delete(key)))
}
await new Promise((resolve) => window.setTimeout(resolve, 500))
const repaired = await navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' })
await repaired.update().catch(() => {})
return navigator.serviceWorker.ready
}
export const notificationPermission = permission
export const notificationSupport = () => ({
@@ -63,35 +30,12 @@ const saveSubscription = async (subscription) => {
return data
}
const subscribeWithKey = async (registration, applicationServerKey) => {
try {
return await registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })
} catch (error) {
if (applicationServerKey instanceof Uint8Array) {
return registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: applicationServerKey.buffer })
}
throw error
}
}
const subscribeWithRetry = async (registration, applicationServerKey) => {
try {
return await subscribeWithKey(registration, applicationServerKey)
} catch (error) {
await registration.update().catch(() => {})
await new Promise((resolve) => window.setTimeout(resolve, 750))
const activeRegistration = await navigator.serviceWorker.ready
try {
return await subscribeWithKey(activeRegistration, applicationServerKey)
} catch (retryError) {
try {
const repairedRegistration = await repairServiceWorkerRegistration()
return await subscribeWithKey(repairedRegistration, applicationServerKey)
} catch (repairError) {
throw new Error(await explainSubscribeError(repairError || retryError || error))
}
}
}
const sameApplicationServerKey = (subscription, applicationServerKey) => {
const existingKey = subscription?.options?.applicationServerKey
if (!existingKey) return true
const existingBytes = new Uint8Array(existingKey)
if (existingBytes.length !== applicationServerKey.length) return false
return existingBytes.every((byte, index) => byte === applicationServerKey[index])
}
export const subscribeToNotifications = async () => {
@@ -99,22 +43,27 @@ export const subscribeToNotifications = async () => {
if (!support.supported) throw new Error('Push notifications are not supported in this browser.')
if (!support.secure) throw new Error('Push notifications require HTTPS.')
const keyResponse = await fetch('/api/notifications/vapid-public-key')
const keyResponse = await fetch('/api/notifications/vapid-public-key', { cache: 'no-store' })
const keyData = await keyResponse.json().catch(() => ({}))
if (!keyResponse.ok || !keyData.publicKey) throw new Error('VAPID public key is not configured.')
if (!keyData.configured) throw new Error('VAPID private key is not configured on the server.')
const applicationServerKey = urlBase64ToArrayBuffer(keyData.publicKey)
const applicationServerKey = urlBase64ToUint8Array(keyData.publicKey)
const requested = await Notification.requestPermission()
const requested = Notification.permission === 'granted' ? 'granted' : await Notification.requestPermission()
permission.value = requested
if (requested !== 'granted') throw new Error('Notification permission was not granted.')
const registered = await navigator.serviceWorker.register('/sw.js', { scope: '/', updateViaCache: 'none' })
await registered.update().catch(() => {})
const readyRegistration = await navigator.serviceWorker.ready
const existing = await readyRegistration.pushManager.getSubscription()
if (existing) return saveSubscription(existing)
await navigator.serviceWorker.register('/sw.js', { scope: '/' })
const registration = await navigator.serviceWorker.ready
const existing = await registration.pushManager.getSubscription()
if (existing) {
if (sameApplicationServerKey(existing, applicationServerKey)) return saveSubscription(existing)
await existing.unsubscribe().catch(() => false)
}
const subscription = await subscribeWithRetry(readyRegistration, applicationServerKey)
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey,
})
return saveSubscription(subscription)
}

View File

@@ -262,7 +262,8 @@ body.menu-open {
@media (max-width: 900px) {
.intro-header > div {
padding-top: calc(1.25rem + env(safe-area-inset-top));
padding-top: calc(0.55rem + env(safe-area-inset-top));
padding-bottom: 0.55rem;
}
.hero-fold::before {
@@ -279,6 +280,16 @@ body.menu-open {
.mobile-menu-trigger {
display: flex;
bottom: calc(5.25rem + env(safe-area-inset-bottom));
}
.hero-shell {
padding-top: calc(4.75rem + env(safe-area-inset-top));
padding-bottom: calc(6.6rem + env(safe-area-inset-bottom));
}
.benefits-cue {
padding-bottom: 0;
}
}
@@ -981,11 +992,18 @@ body.menu-open {
}
.member-button:hover,
.primary-action:hover {
.primary-action:hover:not(:disabled) {
background: #ffd166;
transform: translateY(-1px);
}
.primary-action:disabled,
.secondary-action:disabled {
cursor: not-allowed;
opacity: 0.46;
transform: none;
}
.ghost-member-button {
border-color: rgba(250, 250, 250, 0.36);
background: transparent;
@@ -1207,6 +1225,32 @@ body.menu-open {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.modal-footer.has-terms-check {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.signup-terms-check {
display: grid;
grid-column: 1 / -1;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.65rem;
border: 1px solid rgba(242, 169, 0, 0.22);
border-radius: 8px;
background: rgba(242, 169, 0, 0.08);
padding: 0.75rem 0.85rem;
color: rgba(255, 255, 255, 0.82);
font-size: 0.86rem;
font-weight: 750;
line-height: 1.35;
}
.signup-terms-check input {
width: 1rem;
height: 1rem;
accent-color: #f2a900;
}
.modal-footer.install-step-footer .primary-action,
.modal-footer.install-step-footer .secondary-action {
display: inline-flex;
@@ -3370,7 +3414,7 @@ body.menu-open {
}
.covenant-box h3 {
margin-bottom: 0.8rem;
margin-bottom: 0.65rem;
color: white;
font-weight: 900;
}
@@ -3379,7 +3423,11 @@ body.menu-open {
list-style: decimal;
padding-left: 1.2rem;
color: rgba(255, 255, 255, 0.68);
line-height: 1.65;
line-height: 1.5;
}
.covenant-box li + li {
margin-top: 0.45rem;
}
@media (max-width: 820px) {