Files
RosterChief/static/js/mobile-app.js
Bernard Siebens 09df5d25b8 Scaffold the mobile member app: PWA shell, push, and app-shell tokens
New `mobile` Django app mounted at /app/ -- the installed PWA for Member
mode (M1-M7, see design_handoff_rosterchief_platform/README.md). Coach
mode (C1-C6) is a later phase and has no routes yet.

Foundation pieces:
- assets/mobile.css: Tailwind v4 theme reusing management.css's design
  tokens (same per-club --tenant-* theming pattern), plus the ice/coach
  accent and mobile's 14px card radius.
- PushSubscription model + pywebpush-based sender (mobile/services/push.py),
  wired to notifications.Notification via a post_save signal so the
  existing notification system gains a push channel without knowing about
  PWAs itself.
- Per-club manifest.webmanifest + service worker (served at /app/sw.js)
  + a server-rendered fallback home-screen icon (club initials on
  secondary_color) for clubs without an uploaded logo -- confirmed with
  the user as the fallback, never a generic RosterChief mark.
- App shell (base.html): navy header, person switcher (every child a
  signed-in parent manages, plus "Me"), bottom tab bar, safe-area insets.
  Vendored htmx + Alpine for the screens built on top of it.
- Placeholder views/routes for all seven M1-M7 screens so the shell is
  fully wired end-to-end before each screen is built out individually.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ECGMEwrc2k4D8VQuwjstj9
2026-08-21 10:27:13 +02:00

56 lines
2.1 KiB
JavaScript

// RosterChief member app -- registers the service worker and exposes
// window.rosterchiefPush.{subscribe,unsubscribe}() for M7 (Notifications)
// to wire a permission-request button to (browsers require a user gesture
// before Notification.requestPermission() will prompt, so this is never
// called automatically on page load).
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => navigator.serviceWorker.register("/app/sw.js"));
}
function urlBase64ToUint8Array(base64String) {
const padding = "=".repeat((4 - (base64String.length % 4)) % 4);
const base64 = (base64String + padding).replace(/-/g, "+").replace(/_/g, "/");
const raw = window.atob(base64);
return Uint8Array.from([...raw].map((char) => char.charCodeAt(0)));
}
async function subscribe() {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) return false;
const permission = await Notification.requestPermission();
if (permission !== "granted") return false;
const publicKey = document.body.dataset.vapidPublicKey;
if (!publicKey) return false;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
});
await fetch("/app/push/subscribe/", {
method: "POST",
headers: { "Content-Type": "application/json", "X-CSRFToken": document.body.dataset.csrftoken || "" },
body: JSON.stringify(subscription.toJSON()),
});
return true;
}
async function unsubscribe() {
if (!("serviceWorker" in navigator)) return;
const registration = await navigator.serviceWorker.ready;
const subscription = await registration.pushManager.getSubscription();
if (!subscription) return;
await fetch("/app/push/subscribe/", {
method: "DELETE",
headers: { "Content-Type": "application/json", "X-CSRFToken": document.body.dataset.csrftoken || "" },
body: JSON.stringify({ endpoint: subscription.endpoint }),
});
await subscription.unsubscribe();
}
window.rosterchiefPush = { subscribe, unsubscribe };