From d4708d6973127e95ab52084eb7fd6583f52294a0 Mon Sep 17 00:00:00 2001 From: Zen0space Date: Sat, 11 Apr 2026 19:56:50 +0800 Subject: [PATCH 1/2] feat: PWA push notifications, install tracking, jotai refactor & branded splash screen Add web push notification system with VAPID keys, subscription management, and auto-prompt for PWA users. Track PWA installs with anonymous-to-authenticated claim flow. Refactor useEffect/useState patterns to jotai atoms for breakpoints and push state (eliminating duplicate resize listeners and capability checks). Add server-rendered branded splash screen with breathing icon animation for zero-flicker PWA cold start, with instant redirect to dashboard. Co-Authored-By: Claude Opus 4 (1M context) --- .../src/app/dashboard/notifications/page.tsx | 762 ++++++++++++++++++ packages/kal-admin/src/components/Sidebar.tsx | 22 + packages/kal-backend/.env.example | 8 +- packages/kal-backend/package.json | 2 + packages/kal-backend/src/index.ts | 2 +- packages/kal-backend/src/lib/pwa-installs.ts | 23 + packages/kal-backend/src/lib/web-push.ts | 186 +++++ packages/kal-backend/src/routers/index.ts | 4 + packages/kal-backend/src/routers/push.ts | 187 +++++ packages/kal-backend/src/routers/pwa.ts | 138 ++++ ...0260411000001_create_push_subscriptions.js | 61 ++ .../20260411000002_create_pwa_installs.js | 62 ++ packages/kal-frontend/.env.example | 4 + packages/kal-frontend/next.config.mjs | 1 + .../public/apple-touch-icon-precomposed.png | Bin 0 -> 5368 bytes .../kal-frontend/public/apple-touch-icon.png | Bin 0 -> 5368 bytes .../src/app/dashboard/settings/client.tsx | 150 +++- packages/kal-frontend/src/app/globals.css | 6 + packages/kal-frontend/src/app/layout.tsx | 17 +- packages/kal-frontend/src/app/page.tsx | 52 ++ packages/kal-frontend/src/app/sw.ts | 62 ++ packages/kal-frontend/src/atoms/breakpoint.ts | 85 ++ .../kal-frontend/src/atoms/push-prompt.ts | 11 + packages/kal-frontend/src/atoms/push-state.ts | 20 + packages/kal-frontend/src/atoms/pwa.ts | 41 + .../src/components/BreakpointProvider.tsx | 32 + .../src/components/PushPermissionPrompt.tsx | 210 +++++ .../src/components/PushStateProvider.tsx | 58 ++ .../src/components/PwaAuthRedirect.tsx | 59 ++ .../src/components/PwaInstallTracker.tsx | 132 +++ .../kal-frontend/src/hooks/useBreakpoint.ts | 106 +-- .../src/lib/push-notifications.ts | 191 +++++ .../src/lib/push-prompt-storage.ts | 86 ++ .../src/lib/pwa-install-tracker.ts | 136 ++++ packages/kal-frontend/tailwind.config.ts | 5 + pnpm-lock.yaml | 100 +++ 36 files changed, 2919 insertions(+), 102 deletions(-) create mode 100644 packages/kal-admin/src/app/dashboard/notifications/page.tsx create mode 100644 packages/kal-backend/src/lib/pwa-installs.ts create mode 100644 packages/kal-backend/src/lib/web-push.ts create mode 100644 packages/kal-backend/src/routers/push.ts create mode 100644 packages/kal-backend/src/routers/pwa.ts create mode 100644 packages/kal-db/migrations/20260411000001_create_push_subscriptions.js create mode 100644 packages/kal-db/migrations/20260411000002_create_pwa_installs.js create mode 100644 packages/kal-frontend/public/apple-touch-icon-precomposed.png create mode 100644 packages/kal-frontend/public/apple-touch-icon.png create mode 100644 packages/kal-frontend/src/atoms/breakpoint.ts create mode 100644 packages/kal-frontend/src/atoms/push-prompt.ts create mode 100644 packages/kal-frontend/src/atoms/push-state.ts create mode 100644 packages/kal-frontend/src/atoms/pwa.ts create mode 100644 packages/kal-frontend/src/components/BreakpointProvider.tsx create mode 100644 packages/kal-frontend/src/components/PushPermissionPrompt.tsx create mode 100644 packages/kal-frontend/src/components/PushStateProvider.tsx create mode 100644 packages/kal-frontend/src/components/PwaAuthRedirect.tsx create mode 100644 packages/kal-frontend/src/components/PwaInstallTracker.tsx create mode 100644 packages/kal-frontend/src/lib/push-notifications.ts create mode 100644 packages/kal-frontend/src/lib/push-prompt-storage.ts create mode 100644 packages/kal-frontend/src/lib/pwa-install-tracker.ts diff --git a/packages/kal-admin/src/app/dashboard/notifications/page.tsx b/packages/kal-admin/src/app/dashboard/notifications/page.tsx new file mode 100644 index 0000000..5d26885 --- /dev/null +++ b/packages/kal-admin/src/app/dashboard/notifications/page.tsx @@ -0,0 +1,762 @@ +"use client"; + +import { useState } from "react"; + +import { trpc } from "@/lib/trpc"; + +// ─── Stat Card ─────────────────────────────────────────────────────────────── + +function StatCard({ + label, + value, + icon, + color = "primary", +}: { + label: string; + value: string | number; + icon: React.ReactNode; + color?: "primary" | "success" | "warning" | "info"; +}) { + const colorMap: Record = { + primary: "text-primary-light bg-primary/10 border-primary/15", + success: + "text-status-success bg-status-success/10 border-status-success/15", + warning: + "text-status-warning bg-status-warning/10 border-status-warning/15", + info: "text-status-info bg-status-info/10 border-status-info/15", + }; + + return ( +
+
+ {icon} +
+
+

+ {label} +

+

{value}

+
+
+ ); +} + +// ─── Send Result Banner ────────────────────────────────────────────────────── + +function ResultBanner({ + result, + onDismiss, +}: { + result: { sent: number; failed: number; expired: number }; + onDismiss: () => void; +}) { + const total = result.sent + result.failed + result.expired; + const isSuccess = result.sent > 0 && result.failed === 0; + + return ( +
+
+
+

+ Notification sent to {result.sent} of {total} device + {total !== 1 ? "s" : ""} +

+ {(result.failed > 0 || result.expired > 0) && ( +

+ {result.failed > 0 && `${result.failed} failed`} + {result.failed > 0 && result.expired > 0 && ", "} + {result.expired > 0 && `${result.expired} expired (auto-cleaned)`} +

+ )} +
+ +
+ ); +} + +// ─── Main Page ─────────────────────────────────────────────────────────────── + +export default function NotificationsPage() { + // Form state + const [title, setTitle] = useState(""); + const [body, setBody] = useState(""); + const [url, setUrl] = useState(""); + const [target, setTarget] = useState<"all" | "user">("all"); + const [selectedUserId, setSelectedUserId] = useState(""); + const [sendResult, setSendResult] = useState<{ + sent: number; + failed: number; + expired: number; + } | null>(null); + + // Data queries + const { data: stats, isLoading: statsLoading } = + trpc.push.getStats.useQuery(); + const { data: pwaStats, isLoading: pwaStatsLoading } = + trpc.pwa.getStats.useQuery(); + const { data: usersData } = trpc.user.list.useQuery(); + + // Mutations + const sendToAll = trpc.push.sendToAll.useMutation({ + onSuccess: (data) => { + setSendResult(data); + setTitle(""); + setBody(""); + setUrl(""); + }, + }); + + const sendToUser = trpc.push.sendToUser.useMutation({ + onSuccess: (data) => { + setSendResult(data); + setTitle(""); + setBody(""); + setUrl(""); + setSelectedUserId(""); + }, + }); + + const isSending = sendToAll.isPending || sendToUser.isPending; + const error = sendToAll.error || sendToUser.error; + + const canSend = + title.trim() && + body.trim() && + !isSending && + (target === "all" || selectedUserId); + + const handleSend = () => { + if (!canSend) return; + + const payload = { + title: title.trim(), + body: body.trim(), + url: url.trim() || undefined, + }; + + if (target === "all") { + sendToAll.mutate(payload); + } else { + sendToUser.mutate({ ...payload, userId: selectedUserId }); + } + }; + + return ( +
+ {/* Header */} +
+

+ Push Notifications +

+

+ Send push notifications to subscribed PWA users +

+
+ + {/* Push Stats */} +
+ + + + + } + /> + + + + + } + /> +
+ + {/* Push Prompt Analytics */} + {!statsLoading && + ((stats?.promptsConverted ?? 0) > 0 || + (stats?.promptsDismissed ?? 0) > 0) && ( +
+

+ + + + Push Prompt Analytics +

+
+ + + + + } + /> + + + + } + /> + { + const total = + (stats?.promptsConverted ?? 0) + + (stats?.promptsDismissed ?? 0); + if (total === 0) return "—"; + return `${Math.round(((stats?.promptsConverted ?? 0) / total) * 100)}%`; + })() + } + color="info" + icon={ + + + + + } + /> +
+
+ )} + + {/* PWA Install Stats */} +
+

+ + + + + + PWA Installs +

+ +
+ + + + + } + /> + + + + } + /> + + + + + + } + /> +
+ + {/* Platform & Browser Breakdown */} + {pwaStats && + (pwaStats.platformBreakdown.length > 0 || + pwaStats.browserBreakdown.length > 0) && ( +
+ {/* Platform Breakdown */} + {pwaStats.platformBreakdown.length > 0 && ( +
+

+ By Platform +

+
+ {pwaStats.platformBreakdown.map((p) => ( +
+ + {p.platform} + +
+
+
0 ? (p.count / pwaStats.totalInstalls) * 100 : 0}%`, + }} + /> +
+ + {p.count} + +
+
+ ))} +
+
+ )} + + {/* Browser Breakdown */} + {pwaStats.browserBreakdown.length > 0 && ( +
+

+ By Browser +

+
+ {pwaStats.browserBreakdown.map((b) => ( +
+ + {b.browser} + +
+
+
0 ? (b.count / pwaStats.totalInstalls) * 100 : 0}%`, + }} + /> +
+ + {b.count} + +
+
+ ))} +
+
+ )} +
+ )} + + {/* Daily Installs */} + {pwaStats && pwaStats.dailyInstalls.length > 0 && ( +
+

+ Last 30 Days +

+
+ {pwaStats.dailyInstalls.map((d) => { + const max = Math.max( + ...pwaStats.dailyInstalls.map((i) => i.count) + ); + const heightPct = max > 0 ? (d.count / max) * 100 : 0; + return ( +
+
+ {d.date}: {d.count} +
+
+ ); + })} +
+
+ )} + + {!pwaStatsLoading && pwaStats?.totalInstalls === 0 && ( +

+ No PWA installs recorded yet. Installs are tracked automatically + when users add the app to their home screen. +

+ )} +
+ + {/* Send Result */} + {sendResult && ( + setSendResult(null)} + /> + )} + + {/* Compose Form */} +
+

+ + + + Compose Notification +

+ +
+ {/* Target selection */} +
+ +
+ + +
+
+ + {/* User selector (when target = user) */} + {target === "user" && ( +
+ + +
+ )} + + {/* Title */} +
+ + setTitle(e.target.value)} + placeholder="e.g. New Feature Available!" + maxLength={100} + className="w-full bg-admin-elevated border border-admin-border rounded-lg px-3 py-2 text-sm text-text-primary placeholder:text-text-muted/50 + focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary/30" + /> +

+ {title.length}/100 +

+
+ + {/* Body */} +
+ +