1import { Controller } from "@hotwired/stimulus" 2 3// Handles browser push opt-in for both staff and customers. Registers the 4// service worker, requests notification permission, subscribes via the Push API, 5// and persists the subscription server-side. Degrades gracefully when the 6// browser lacks support or the user has blocked notifications. 7export default class extends Controller { 8 static targets = ["prompt", "enableButton", "blockedHint", "enabledHint", "unsupported", "errorHint"] 9 static values = { 10 publicKey: String, 11 subscribeUrl: String, 12 unsubscribeUrl: String, 13 context: String, 14 locationId: String, 15 orderId: String, 16 orderToken: String, 17 auto: Boolean 18 } 19 20 connect() { 21 if (!this.supported()) { 22 this.hide(this.prompt) 23 this.hide(this.blockedHint) 24 this.hide(this.enabledHint) 25 this.show(this.unsupported) 26 return 27 } 28 this.reflectState() 29 } 30 31 supported() { 32 return "serviceWorker" in navigator && "PushManager" in window && "Notification" in window 33 } 34 35 get prompt() { 36 return this.hasPromptTarget ? this.promptTarget : null 37 } 38 39 get blockedHint() { 40 return this.hasBlockedHintTarget ? this.blockedHintTarget : null 41 } 42 43 get enabledHint() { 44 return this.hasEnabledHintTarget ? this.enabledHintTarget : null 45 } 46 47 get unsupported() { 48 return this.hasUnsupportedTarget ? this.unsupportedTarget : null 49 } 50 51 get errorHint() { 52 return this.hasErrorHintTarget ? this.errorHintTarget : null 53 } 54 55 reflectState() { 56 const permission = Notification.permission 57 this.hide(this.errorHint) 58 if (permission === "granted") { 59 this.hide(this.prompt) 60 this.hide(this.blockedHint) 61 // Permission is granted, but a valid push subscription may still be 62 // missing (e.g. it rotated, or this browser has no push service). Only 63 // claim "enabled" once we've actually confirmed a subscription. 64 this.confirmSubscription() 65 } else if (permission === "denied") { 66 this.hide(this.prompt) 67 this.hide(this.enabledHint) 68 this.show(this.blockedHint) 69 } else { 70 this.show(this.prompt) 71 this.hide(this.blockedHint) 72 this.hide(this.enabledHint) 73 } 74 } 75 76 async confirmSubscription() { 77 const ok = await this.subscribe(true) 78 if (ok) { 79 this.hide(this.prompt) 80 this.hide(this.errorHint) 81 this.hide(this.unsupported) 82 this.show(this.enabledHint) 83 } else { 84 this.hide(this.enabledHint) 85 this.handleSubscribeFailure() 86 } 87 } 88 89 async enable() { 90 if (!this.supported() || !this.publicKeyValue) return 91 92 const permission = await Notification.requestPermission() 93 if (permission === "granted") { 94 const ok = await this.subscribe(false) 95 this.hide(this.prompt) 96 this.hide(this.blockedHint) 97 if (ok) { 98 this.hide(this.errorHint) 99 this.hide(this.unsupported) 100 this.show(this.enabledHint) 101 } else { 102 this.hide(this.enabledHint) 103 this.handleSubscribeFailure() 104 } 105 } else if (permission === "denied") { 106 this.hide(this.prompt) 107 this.hide(this.enabledHint) 108 this.show(this.blockedHint) 109 } 110 } 111 112 // The browser exposes the Push APIs but couldn't create a subscription. The 113 // most common cause is a privacy-focused browser with its push service off â 114 // notably Brave, which needs "Use Google services for push messaging" enabled 115 // (brave://settings/privacy). Show the actionable errorHint (which explains 116 // this) rather than the misleading "not supported" message. 117 handleSubscribeFailure() { 118 this.hide(this.unsupported) 119 if (this.errorHint) { 120 this.hide(this.prompt) 121 this.show(this.errorHint) 122 } else { 123 // No dedicated error slot: re-offer the prompt so the user can retry. 124 this.show(this.prompt) 125 } 126 } 127 128 // Registers the service worker and ensures a push subscription exists, then 129 // persists it server-side. Returns true only when a subscription is active. 130 async subscribe(silent) { 131 try { 132 const registration = await navigator.serviceWorker.register("/sw.js") 133 await navigator.serviceWorker.ready 134 135 let subscription = await registration.pushManager.getSubscription() 136 137 // On an explicit opt-in, drop any stale subscription first so we always 138 // re-subscribe against the current VAPID key. This recovers from a 139 // rotated key or a subscription the server no longer recognizes. 140 if (subscription && !silent) { 141 await subscription.unsubscribe() 142 subscription = null 143 } 144 145 if (!subscription) { 146 subscription = await registration.pushManager.subscribe({ 147 userVisibleOnly: true, 148 applicationServerKey: this.urlBase64ToUint8Array(this.publicKeyValue) 149 }) 150 } 151 152 const response = await this.persist(subscription) 153 return Boolean(subscription) && response 154 } catch (error) { 155 if (!silent) console.warn("Push subscription failed", error) 156 return false 157 } 158 } 159 160 async persist(subscription) { 161 const json = subscription.toJSON() 162 const body = { 163 endpoint: subscription.endpoint, 164 p256dh: json.keys ? json.keys.p256dh : null, 165 auth: json.keys ? json.keys.auth : null, 166 context: this.hasContextValue ? this.contextValue : "customer", 167 location_id: this.hasLocationIdValue ? this.locationIdValue : null, 168 order_id: this.hasOrderIdValue ? this.orderIdValue : null, 169 order_token: this.hasOrderTokenValue ? this.orderTokenValue : null 170 } 171 172 const response = await fetch(this.subscribeUrlValue, { 173 method: "POST", 174 credentials: "same-origin", 175 headers: { 176 "Content-Type": "application/json", 177 Accept: "application/json", 178 "X-CSRF-Token": this.csrfToken() 179 }, 180 body: JSON.stringify({ push_subscription: body }) 181 }) 182 return response.ok 183 } 184 185 dismiss() { 186 this.hide(this.prompt) 187 } 188 189 urlBase64ToUint8Array(base64String) { 190 const padding = "=".repeat((4 - (base64String.length % 4)) % 4) 191 const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/") 192 const rawData = window.atob(base64) 193 const output = new Uint8Array(rawData.length) 194 for (let i = 0; i < rawData.length; ++i) output[i] = rawData.charCodeAt(i) 195 return output 196 } 197 198 csrfToken() { 199 const meta = document.querySelector('meta[name="csrf-token"]') 200 return meta ? meta.content : "" 201 } 202 203 show(el) { 204 if (!el) return 205 el.classList.remove("hidden") 206 el.style.display = el.classList.contains("alert") ? "flex" : "" 207 } 208 209 hide(el) { 210 if (!el) return 211 el.classList.add("hidden") 212 el.style.display = "none" 213 } 214};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.