diff --git a/engine/app/assets/stylesheets/coplan/application.css b/engine/app/assets/stylesheets/coplan/application.css index bb8fd44..b8807c1 100644 --- a/engine/app/assets/stylesheets/coplan/application.css +++ b/engine/app/assets/stylesheets/coplan/application.css @@ -2425,3 +2425,98 @@ body:has(.comment-toolbar) .main-content { color: var(--color-text-muted); font-size: 0.9em; } + +/* Web Push encouragement banner — bottom-right toast that appears after a + user posts a comment, inviting them to enable browser notifications. + Stays above the comment toolbar (z-index 50) and any thread popovers. */ +.web-push-banner { + position: fixed; + right: var(--space-lg); + /* Sit above the comment toolbar (when present) without overlapping. */ + bottom: calc(var(--space-lg) + 3.5rem); + z-index: 60; + display: flex; + align-items: flex-start; + gap: var(--space-md); + width: min(420px, calc(100vw - var(--space-lg) * 2)); + padding: var(--space-md) var(--space-lg); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + animation: web-push-banner-in 220ms ease-out; +} + +body:not(:has(.comment-toolbar)) .web-push-banner { + bottom: var(--space-lg); +} + +@keyframes web-push-banner-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +.web-push-banner[hidden] { + display: none; +} + +.web-push-banner__icon { + flex-shrink: 0; + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--color-surface-muted); + color: var(--color-text); + border-radius: 999px; +} + +.web-push-banner__main { + flex: 1; + min-width: 0; +} + +.web-push-banner__title { + font-weight: 600; + font-size: var(--text-sm); + color: var(--color-text); + margin-bottom: 2px; +} + +.web-push-banner__hint { + font-size: var(--text-sm); + color: var(--color-text-muted); + line-height: 1.4; +} + +.web-push-banner__hint a { + color: var(--color-text); + text-decoration: underline; +} + +.web-push-banner__actions { + display: flex; + justify-content: flex-end; + gap: var(--space-sm); + margin-top: var(--space-md); +} + +.web-push-banner__btn { + /* Slightly chunkier than the default btn--sm so the CTA reads as a real + decision point rather than a footnote. */ + padding: 0.4rem 0.9rem; + font-weight: 600; +} + +@media (max-width: 600px) { + .web-push-banner { + right: var(--space-md); + left: var(--space-md); + width: auto; + } + + .web-push-banner__actions .btn { + flex: 1; + } +} diff --git a/engine/app/javascript/controllers/coplan/comment_form_controller.js b/engine/app/javascript/controllers/coplan/comment_form_controller.js index b770e3b..c4cbca7 100644 --- a/engine/app/javascript/controllers/coplan/comment_form_controller.js +++ b/engine/app/javascript/controllers/coplan/comment_form_controller.js @@ -25,22 +25,38 @@ export default class extends Controller { this._docClickHandler = this.handleDocumentClick.bind(this) this._inputHandler = this.handleInput.bind(this) this._keydownHandler = this.handleKeydown.bind(this) + this._submitEndHandler = this.handleSubmitEnd.bind(this) this.element.addEventListener("input", this._inputHandler) // keydown listens with capture so the picker handles Enter/Arrows // before the legacy submitOnEnter action fires. this.element.addEventListener("keydown", this._keydownHandler, true) document.addEventListener("click", this._docClickHandler) + + // Notify the global Web Push encouragement banner that this user just + // posted a comment — gives the banner a chance to surface itself. + this._submitForm = this.element.closest("form") + if (this._submitForm) { + this._submitForm.addEventListener("turbo:submit-end", this._submitEndHandler) + } } disconnect() { this.element.removeEventListener("input", this._inputHandler) this.element.removeEventListener("keydown", this._keydownHandler, true) document.removeEventListener("click", this._docClickHandler) + if (this._submitForm) { + this._submitForm.removeEventListener("turbo:submit-end", this._submitEndHandler) + } this.closePicker() if (this._debounce) clearTimeout(this._debounce) } + handleSubmitEnd(event) { + if (!event.detail?.success) return + document.dispatchEvent(new CustomEvent("coplan:web-push-banner:nudge")) + } + // Legacy keep-alive entry point (still wired in views via data-action). // Real Enter handling now happens in handleKeydown. submitOnEnter(_event) {} diff --git a/engine/app/javascript/controllers/coplan/web_push_banner_controller.js b/engine/app/javascript/controllers/coplan/web_push_banner_controller.js new file mode 100644 index 0000000..784ef7f --- /dev/null +++ b/engine/app/javascript/controllers/coplan/web_push_banner_controller.js @@ -0,0 +1,95 @@ +import { Controller } from "@hotwired/stimulus" +import * as WebPush from "coplan/web_push" + +// Encouragement banner. Listens for `coplan:web-push-banner:nudge` events +// (dispatched by the comment form on successful submit) and shows a small +// toast-style banner inviting the user to enable browser notifications. +// +// Eligibility (all must hold): +// - browser supports Notification + PushManager + we have a VAPID key +// - permission isn't already "denied" +// - not already subscribed on this device +// - the user hasn't dismissed N times (default 3) +// - the user hasn't already enabled via the banner before +// +// Dismissals and the permanent-off flag live in localStorage so they're +// per-device, which matches how subscriptions work. +const DISMISS_KEY = "coplan.web_push_banner.dismissals" +const PERMANENT_KEY = "coplan.web_push_banner.permanent_off" + +export default class extends Controller { + static targets = ["title", "hint", "actions"] + static values = { + maxDismissals: { type: Number, default: 3 }, + settingsUrl: { type: String, default: "" } + } + + connect() { + this._onNudge = this._onNudge.bind(this) + document.addEventListener("coplan:web-push-banner:nudge", this._onNudge) + } + + disconnect() { + document.removeEventListener("coplan:web-push-banner:nudge", this._onNudge) + } + + async _onNudge() { + if (!(await this._eligible())) return + this.element.hidden = false + } + + async _eligible() { + if (localStorage.getItem(PERMANENT_KEY) === "1") return false + if (!WebPush.isSupported()) return false + if (WebPush.permission() === "denied") return false + if (await WebPush.isSubscribed()) return false + if (this._dismissCount() >= this.maxDismissalsValue) return false + return true + } + + async enable() { + this._setMessage("Enabling…", "") + try { + await WebPush.subscribe() + this._setMessage( + "All set!", + "You'll get a desktop notification when someone replies." + ) + this.actionsTarget.hidden = true + // Mark permanent so we never nag again on this device — they're already in. + localStorage.setItem(PERMANENT_KEY, "1") + setTimeout(() => { this.element.hidden = true }, 4000) + } catch (err) { + this._setMessage( + "Couldn't enable notifications.", + this._friendlyError(err) + ) + } + } + + dismiss() { + const next = this._dismissCount() + 1 + localStorage.setItem(DISMISS_KEY, String(next)) + if (next >= this.maxDismissalsValue) { + localStorage.setItem(PERMANENT_KEY, "1") + } + this.element.hidden = true + } + + _dismissCount() { + const raw = localStorage.getItem(DISMISS_KEY) + const n = parseInt(raw || "0", 10) + return Number.isFinite(n) ? n : 0 + } + + _setMessage(title, hint) { + if (this.hasTitleTarget) this.titleTarget.textContent = title + if (this.hasHintTarget) this.hintTarget.textContent = hint + } + + _friendlyError(err) { + const msg = err?.message || String(err) + if (/permission/i.test(msg)) return "Permission was not granted." + return msg + } +} diff --git a/engine/app/views/coplan/shared/_web_push_banner.html.erb b/engine/app/views/coplan/shared/_web_push_banner.html.erb new file mode 100644 index 0000000..2017abb --- /dev/null +++ b/engine/app/views/coplan/shared/_web_push_banner.html.erb @@ -0,0 +1,37 @@ +<%# Toast-style nudge that appears after a signed-in user posts a comment, %> +<%# encouraging them to enable browser notifications on this device. The %> +<%# Stimulus controller decides whether to actually show it (browser support, %> +<%# existing subscription, dismissal count) — the partial just provides the %> +<%# DOM in a hidden state. %> +<% return unless signed_in? && CoPlan.configuration.web_push_configured? %> + + diff --git a/engine/app/views/layouts/coplan/application.html.erb b/engine/app/views/layouts/coplan/application.html.erb index 9befcd5..da92256 100644 --- a/engine/app/views/layouts/coplan/application.html.erb +++ b/engine/app/views/layouts/coplan/application.html.erb @@ -66,5 +66,6 @@ <% end %> <%= yield %> + <%= render "coplan/shared/web_push_banner" %>