Skip to content

Commit f35f82d

Browse files
scriptcodedclaude
andcommitted
feat: improve push notification registration and onboarding
- Extract Firebase init and token registration into shared firebase.ts module - Move notification setup to __root.tsx so it runs regardless of route - Add standardised icon/badge constants used across app and service worker - Add credentials: include to notifications API client and register fetch - Gate onboarding accept button behind sign-in check with login link - Add distinct error state for failed registration vs denied permission - Add test notification button to settings page - Add 15s timeout to navigator.serviceWorker.ready to avoid hanging Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2d929fa commit f35f82d

11 files changed

Lines changed: 210 additions & 74 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
"Bash(pnpm add:*)",
55
"Bash(pnpm build:*)",
66
"Bash(pnpm run *)",
7-
"Bash(pnpm exec tsc)"
7+
"Bash(pnpm exec tsc)",
8+
"Bash(pnpm tsc *)"
89
]
910
}
1011
}

public/notification-badge.png

5.01 KB
Loading

public/notification-badge.svg

Lines changed: 22 additions & 0 deletions
Loading

src/notifications/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
import createClient from "openapi-fetch";
22
import type { paths } from "../generated/notification-api";
33

4-
export const client = createClient<paths>({ baseUrl: "/notifications" });
4+
export const client = createClient<paths>({
5+
baseUrl: "/notifications",
6+
credentials: "include",
7+
});

src/notifications/firebase.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { initializeApp } from "firebase/app";
2+
import { getMessaging, getToken } from "firebase/messaging";
3+
import { configPromise } from "../config";
4+
5+
const FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG);
6+
const FIREBASE_VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY;
7+
8+
const firebaseApp = initializeApp(FIREBASE_CONFIG);
9+
export const messaging = getMessaging(firebaseApp);
10+
11+
const SW_READY_TIMEOUT_MS = 15_000;
12+
13+
export async function registerForPushNotifications(): Promise<void> {
14+
const registration = await Promise.race([
15+
navigator.serviceWorker.ready,
16+
new Promise<never>((_, reject) =>
17+
setTimeout(
18+
() => reject(new Error("Service worker not ready")),
19+
SW_READY_TIMEOUT_MS,
20+
),
21+
),
22+
]);
23+
24+
const token = await getToken(messaging, {
25+
vapidKey: FIREBASE_VAPID_KEY,
26+
serviceWorkerRegistration: registration,
27+
});
28+
29+
const { notificationsTenant } = await configPromise;
30+
31+
const res = await fetch(
32+
`/notifications/api/tenants/${notificationsTenant}/register`,
33+
{
34+
method: "POST",
35+
headers: { "Content-Type": "application/json" },
36+
credentials: "include",
37+
body: JSON.stringify({ tokens: [token] }),
38+
},
39+
);
40+
41+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
42+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const NOTIFICATION_ICON = "/web-app-manifest-192x192.png";
2+
export const NOTIFICATION_BADGE = "/notification-badge.png";

src/routes/__root.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,33 @@
11
import { createRootRoute, Outlet } from "@tanstack/react-router";
2+
import { onMessage } from "firebase/messaging";
3+
import { useEffect } from "react";
4+
import {
5+
messaging,
6+
registerForPushNotifications,
7+
} from "../notifications/firebase";
28

39
const RootLayout = () => {
10+
useEffect(() => {
11+
if (Notification.permission === "granted") {
12+
registerForPushNotifications().catch((e) => {
13+
console.error("Failed to register for push notifications:", e);
14+
});
15+
}
16+
17+
let unsubscribeMessage: (() => void) | undefined;
18+
navigator.serviceWorker.ready.then((registration) => {
19+
unsubscribeMessage = onMessage(messaging, (payload) => {
20+
console.log("Foreground message received:", payload);
21+
registration.showNotification(
22+
payload.notification?.title || "Notification",
23+
{ body: payload.notification?.body },
24+
);
25+
});
26+
});
27+
28+
return () => unsubscribeMessage?.();
29+
}, []);
30+
431
return <Outlet />;
532
};
633

src/routes/_app.tsx

Lines changed: 0 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,6 @@ import {
55
useRouter,
66
useRouterState,
77
} from "@tanstack/react-router";
8-
import { initializeApp } from "firebase/app";
9-
import { getMessaging, getToken, onMessage } from "firebase/messaging";
108
import { useAtomValue, useSetAtom } from "jotai";
119
import { Suspense, useEffect } from "react";
1210
import { AppBar } from "../components/AppBar";
@@ -19,9 +17,6 @@ import { PwaReloadBanner } from "../components/PwaReloadBanner";
1917
import { onboardedAtom } from "../onboarding";
2018
import { pageTitleAtom } from "../pageState";
2119

22-
const FIREBASE_CONFIG = JSON.parse(import.meta.env.VITE_FIREBASE_CONFIG);
23-
const FIREBASE_VAPID_KEY = import.meta.env.VITE_FIREBASE_VAPID_KEY;
24-
2520
export const Route = createFileRoute("/_app")({
2621
component: RouteComponent,
2722
});
@@ -57,55 +52,6 @@ function RouteComponent() {
5752
},
5853
});
5954

60-
useEffect(() => {
61-
navigator.serviceWorker.ready.then((registration) => {
62-
const firebaseApp = initializeApp(FIREBASE_CONFIG);
63-
const messaging = getMessaging(firebaseApp);
64-
65-
getToken(messaging, {
66-
vapidKey: FIREBASE_VAPID_KEY,
67-
serviceWorkerRegistration: registration,
68-
})
69-
.then((token) => {
70-
console.log("FCM token:", token);
71-
72-
onMessage(messaging, (payload) => {
73-
console.log("Foreground message received:", payload);
74-
75-
registration.showNotification(
76-
payload.notification?.title || "Notification",
77-
{
78-
body: payload.notification?.body,
79-
},
80-
);
81-
});
82-
83-
fetch("/notifications/api/tenants/jamboree26/register", {
84-
method: "POST",
85-
headers: {
86-
"Content-Type": "application/json",
87-
},
88-
body: JSON.stringify({ tokens: [token] }),
89-
})
90-
.then((res) => {
91-
if (!res.ok) {
92-
throw new Error(
93-
`Failed to register FCM token: ${res.statusText}`,
94-
);
95-
}
96-
97-
console.log("FCM token registered with backend");
98-
})
99-
.catch((e) => {
100-
console.error("Failed to register FCM token with backend:", e);
101-
});
102-
})
103-
.catch((e) => {
104-
console.error("Failed to get FCM token:", e);
105-
});
106-
});
107-
}, []);
108-
10955
// biome-ignore lint/correctness/useExhaustiveDependencies: We only want this to run on load
11056
useEffect(() => {
11157
if (!onboarded) {

src/routes/_app/settings/notifications.tsx

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
import {
2+
ScoutButton,
23
ScoutCallout,
34
ScoutListView,
45
ScoutListViewItem,
56
} from "@scouterna/ui-react";
67
import { useMutation, useQuery } from "@tanstack/react-query";
78
import { createFileRoute } from "@tanstack/react-router";
9+
import { useState } from "react";
810
import { PageContainer } from "../../../components/PageContainer";
911
import type { components } from "../../../generated/notification-api";
1012
import * as api from "../../../notifications/api";
13+
import {
14+
NOTIFICATION_BADGE,
15+
NOTIFICATION_ICON,
16+
} from "../../../notifications/notification-defaults";
1117

1218
export const Route = createFileRoute("/_app/settings/notifications")({
1319
component: RouteComponent,
@@ -56,6 +62,44 @@ const ChannelRow = ({
5662
);
5763
};
5864

65+
async function sendTestNotification() {
66+
if (Notification.permission !== "granted") return "denied";
67+
68+
const reg = await navigator.serviceWorker.ready;
69+
await reg.showNotification("Testnotis", {
70+
body: "Det här är en testnotis från Jamboree-appen.",
71+
icon: NOTIFICATION_ICON,
72+
badge: NOTIFICATION_BADGE,
73+
});
74+
return "sent";
75+
}
76+
77+
function TestNotificationButton() {
78+
const [status, setStatus] = useState<"idle" | "sent" | "denied">("idle");
79+
80+
const handleClick = async () => {
81+
const result = await sendTestNotification();
82+
setStatus(result);
83+
if (result === "sent") setTimeout(() => setStatus("idle"), 3000);
84+
};
85+
86+
return (
87+
<div className="p-4 flex flex-col gap-3">
88+
<ScoutButton variant="outlined" onScoutClick={handleClick}>
89+
Skicka testnotis
90+
</ScoutButton>
91+
{status === "sent" && (
92+
<ScoutCallout variant="success">Testnotis skickad!</ScoutCallout>
93+
)}
94+
{status === "denied" && (
95+
<ScoutCallout variant="error">
96+
Notiser är inte tillåtna. Aktivera dem i telefonens inställningar.
97+
</ScoutCallout>
98+
)}
99+
</div>
100+
);
101+
}
102+
59103
function RouteComponent() {
60104
const channels = useQuery({
61105
queryFn: api.getChannels,
@@ -80,6 +124,8 @@ function RouteComponent() {
80124
</ScoutCallout>
81125
</div>
82126

127+
<TestNotificationButton />
128+
83129
<ScoutListView>
84130
<ScoutListViewItem
85131
type="checkbox"

0 commit comments

Comments
 (0)