diff --git a/Taskfile.yml b/Taskfile.yml index 13d3db2d..728872bd 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -39,6 +39,11 @@ tasks: - git config core.hooksPath .github/hooks - echo "Git hooks configured successfully." + gen-vapid: + desc: Generate a VAPID keypair for push notifications (prints to stdout; copy into .env) + cmds: + - go run ./cmd/genvapid + test: desc: Run all Go tests cmds: diff --git a/client/web/src/components/PushPromptHost.tsx b/client/web/src/components/PushPromptHost.tsx new file mode 100644 index 00000000..777ddd3c --- /dev/null +++ b/client/web/src/components/PushPromptHost.tsx @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { toast } from "sonner"; + +import { usePushPrompt } from "@/shared/push/usePushPrompt"; + +const TOAST_ID = "push-prompt"; + +export function PushPromptHost() { + const { shouldPrompt, accept, dismiss } = usePushPrompt(); + + useEffect(() => { + if (!shouldPrompt) return; + + toast("Get notified about your application status", { + id: TOAST_ID, + description: + "Allow push notifications so we can let you know when reviews and announcements drop.", + duration: Infinity, + action: { + label: "Enable", + onClick: () => { + void accept(); + }, + }, + cancel: { + label: "Not now", + onClick: () => { + dismiss(); + }, + }, + }); + + return () => { + toast.dismiss(TOAST_ID); + }; + }, [shouldPrompt, accept, dismiss]); + + return null; +} diff --git a/client/web/src/pages/admin/_shared/AppSidebar.tsx b/client/web/src/pages/admin/_shared/AppSidebar.tsx index 6f31e781..60412b39 100644 --- a/client/web/src/pages/admin/_shared/AppSidebar.tsx +++ b/client/web/src/pages/admin/_shared/AppSidebar.tsx @@ -1,6 +1,7 @@ "use client"; import { + Bell, Calendar, ClipboardList, Handshake, @@ -75,6 +76,11 @@ const superAdminNav = [ url: "/admin/sa/application", icon: ClipboardList, }, + { + name: "Notifications", + url: "/admin/sa/notifications", + icon: Bell, + }, ]; export function AppSidebar({ ...props }: React.ComponentProps) { diff --git a/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx b/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx new file mode 100644 index 00000000..cd04a4d0 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/NotificationsPage.tsx @@ -0,0 +1,54 @@ +import { useEffect } from "react"; + +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +import { NotificationsTable } from "./components/NotificationsTable"; +import { useNotificationsStore } from "./store"; + +export default function NotificationsPage() { + const { + notifications, + loading, + saving, + fetch: fetchNotifications, + create, + update, + remove, + } = useNotificationsStore(); + + useEffect(() => { + const controller = new AbortController(); + fetchNotifications(controller.signal); + return () => controller.abort(); + }, [fetchNotifications]); + + if (loading && notifications.length === 0) { + return ( +
+ + + + + + {[...Array(3)].map((_, i) => ( + + ))} + + +
+ ); + } + + return ( +
+ +
+ ); +} diff --git a/client/web/src/pages/superadmin/notifications/api.ts b/client/web/src/pages/superadmin/notifications/api.ts new file mode 100644 index 00000000..49c0a3c6 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/api.ts @@ -0,0 +1,59 @@ +import { + deleteRequest, + getRequest, + patchRequest, + postRequest, +} from "@/shared/lib/api"; +import type { ApiResponse } from "@/types"; + +import type { + ScheduledNotification, + ScheduledNotificationListResponse, + ScheduledNotificationPayload, +} from "./types"; + +export async function fetchScheduledNotifications( + signal?: AbortSignal, +): Promise> { + return getRequest( + "/superadmin/notifications", + "scheduled notifications", + signal, + ); +} + +export async function createScheduledNotification( + payload: ScheduledNotificationPayload, + signal?: AbortSignal, +): Promise> { + return postRequest( + "/superadmin/notifications", + payload, + "scheduled notification", + signal, + ); +} + +export async function updateScheduledNotification( + id: string, + payload: ScheduledNotificationPayload, + signal?: AbortSignal, +): Promise> { + return patchRequest( + `/superadmin/notifications/${id}`, + payload, + "scheduled notification", + signal, + ); +} + +export async function deleteScheduledNotification( + id: string, + signal?: AbortSignal, +): Promise> { + return deleteRequest( + `/superadmin/notifications/${id}`, + "scheduled notification", + signal, + ); +} diff --git a/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx b/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx new file mode 100644 index 00000000..f8cbd6b8 --- /dev/null +++ b/client/web/src/pages/superadmin/notifications/components/NotificationFormDialog.tsx @@ -0,0 +1,216 @@ +import { Loader2 } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import type { UserRole } from "@/types"; + +import type { + ScheduledNotification, + ScheduledNotificationPayload, +} from "../types"; + +const TARGET_ALL = "__all"; +type TargetOption = UserRole | typeof TARGET_ALL; + +const ROLE_OPTIONS: { value: TargetOption; label: string }[] = [ + { value: TARGET_ALL, label: "All users" }, + { value: "hacker", label: "Hackers" }, + { value: "admin", label: "Admins" }, + { value: "super_admin", label: "Super Admins" }, +]; + +function defaultScheduledLocal(): string { + const now = new Date(Date.now() + 5 * 60 * 1000); + now.setSeconds(0, 0); + return toLocalInputValue(now.toISOString()); +} + +function toLocalInputValue(iso: string): string { + const d = new Date(iso); + const tzOffsetMs = d.getTimezoneOffset() * 60_000; + const local = new Date(d.getTime() - tzOffsetMs); + return local.toISOString().slice(0, 16); +} + +interface NotificationFormProps { + notification: ScheduledNotification | null; + saving: boolean; + onSubmit: (payload: ScheduledNotificationPayload) => Promise; + onCancel: () => void; +} + +function NotificationForm({ + notification, + saving, + onSubmit, + onCancel, +}: NotificationFormProps) { + const [title, setTitle] = useState(notification?.title ?? ""); + const [body, setBody] = useState(notification?.body ?? ""); + const [url, setUrl] = useState(notification?.url ?? ""); + const [target, setTarget] = useState( + notification?.target_role ?? TARGET_ALL, + ); + const [scheduledAt, setScheduledAt] = useState( + notification?.scheduled_at + ? toLocalInputValue(notification.scheduled_at) + : defaultScheduledLocal(), + ); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!title.trim() || !body.trim() || !scheduledAt) return; + + const isoScheduled = new Date(scheduledAt).toISOString(); + const ok = await onSubmit({ + title: title.trim(), + body: body.trim(), + url: url.trim() === "" ? null : url.trim(), + target_role: target === TARGET_ALL ? null : (target as UserRole), + scheduled_at: isoScheduled, + }); + if (ok) { + onCancel(); + } + }; + + return ( +
+
+ + setTitle(e.target.value)} + maxLength={100} + placeholder="Applications close in 1 hour" + required + /> +
+
+ +