diff --git a/app/components/NotificationSettings.tsx b/app/components/NotificationSettings.tsx
new file mode 100644
index 0000000..4fd90c1
--- /dev/null
+++ b/app/components/NotificationSettings.tsx
@@ -0,0 +1,43 @@
+import { Switch } from "~/components/ui/switch";
+import { Label } from "~/components/ui/label";
+
+interface NotificationSettingsProps {
+ enabled: boolean;
+ onEnabledChange: (enabled: boolean) => void;
+ isSupported: boolean;
+ permissionState: NotificationPermission | "unsupported";
+}
+
+export function NotificationSettings({
+ enabled,
+ onEnabledChange,
+ isSupported,
+ permissionState,
+}: NotificationSettingsProps) {
+ if (!isSupported) {
+ return null;
+ }
+
+ return (
+
+
+
+
+
+
+ {permissionState === "denied" && (
+
+ Notifications blocked by browser. Update in browser settings to
+ enable.
+
+ )}
+
+ );
+}
diff --git a/app/components/draft/QueueSection.tsx b/app/components/draft/QueueSection.tsx
index a548575..ba6b992 100644
--- a/app/components/draft/QueueSection.tsx
+++ b/app/components/draft/QueueSection.tsx
@@ -1,6 +1,7 @@
import { Button } from "~/components/ui/button";
import { Badge } from "~/components/ui/badge";
import { AutodraftSettings } from "~/components/AutodraftSettings";
+import { NotificationSettings } from "~/components/NotificationSettings";
import {
DndContext,
closestCenter,
@@ -48,6 +49,10 @@ interface QueueSectionProps {
onRemoveFromQueue: (queueId: string) => void;
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void;
onReorder: (participantIds: string[]) => void;
+ notificationsEnabled: boolean;
+ onNotificationsEnabledChange: (enabled: boolean) => void;
+ notificationsSupported: boolean;
+ notificationsPermission: NotificationPermission | "unsupported";
}
// Sortable queue item component
@@ -130,6 +135,10 @@ export function QueueSection({
onRemoveFromQueue,
onAutodraftUpdate,
onReorder,
+ notificationsEnabled,
+ onNotificationsEnabledChange,
+ notificationsSupported,
+ notificationsPermission,
}: QueueSectionProps) {
const sensors = useSensors(
useSensor(PointerSensor),
@@ -196,6 +205,14 @@ export function QueueSection({
isMyTurn={isMyTurn}
onUpdate={onAutodraftUpdate}
/>
+
+ {/* Notification Settings */}
+
);
}
diff --git a/app/hooks/useDraftNotifications.ts b/app/hooks/useDraftNotifications.ts
new file mode 100644
index 0000000..51f9209
--- /dev/null
+++ b/app/hooks/useDraftNotifications.ts
@@ -0,0 +1,84 @@
+import { useState, useEffect, useCallback } from "react";
+
+function getStorageKey(seasonId: string) {
+ return `draftNotifications-${seasonId}`;
+}
+
+export function useDraftNotifications(seasonId: string) {
+ const [isSupported, setIsSupported] = useState(false);
+ const [permissionState, setPermissionState] = useState<
+ NotificationPermission | "unsupported"
+ >("unsupported");
+ const [enabled, setEnabledState] = useState(false);
+
+ // Check browser support and restore persisted preference
+ useEffect(() => {
+ if (typeof window === "undefined" || !("Notification" in window)) {
+ return;
+ }
+
+ setIsSupported(true);
+ setPermissionState(Notification.permission);
+
+ // Restore preference from localStorage only if permission is granted
+ if (Notification.permission === "granted") {
+ const stored = localStorage.getItem(getStorageKey(seasonId));
+ if (stored === "true") {
+ setEnabledState(true);
+ }
+ }
+ }, [seasonId]);
+
+ const setEnabled = useCallback(
+ async (value: boolean) => {
+ if (!isSupported) return;
+
+ if (value) {
+ // Request permission if not yet granted
+ if (Notification.permission === "default") {
+ const result = await Notification.requestPermission();
+ setPermissionState(result);
+ if (result !== "granted") {
+ return;
+ }
+ } else if (Notification.permission === "denied") {
+ return;
+ }
+
+ setEnabledState(true);
+ localStorage.setItem(getStorageKey(seasonId), "true");
+ } else {
+ setEnabledState(false);
+ localStorage.setItem(getStorageKey(seasonId), "false");
+ }
+ },
+ [isSupported, seasonId]
+ );
+
+ const sendNotification = useCallback(
+ (title: string, body: string) => {
+ if (
+ !enabled ||
+ !isSupported ||
+ Notification.permission !== "granted" ||
+ !document.hidden
+ ) {
+ return;
+ }
+
+ new Notification(title, {
+ body,
+ tag: `draft-${seasonId}`,
+ });
+ },
+ [enabled, isSupported, seasonId]
+ );
+
+ return {
+ isSupported,
+ permissionState,
+ enabled,
+ setEnabled,
+ sendNotification,
+ };
+}
diff --git a/app/routes/leagues/$leagueId.draft.$seasonId.tsx b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
index a06917f..729a913 100644
--- a/app/routes/leagues/$leagueId.draft.$seasonId.tsx
+++ b/app/routes/leagues/$leagueId.draft.$seasonId.tsx
@@ -18,6 +18,7 @@ import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
import { DraftGridSection } from "~/components/draft/DraftGridSection";
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
+import { useDraftNotifications } from "~/hooks/useDraftNotifications";
import { toast } from "sonner";
export async function loader(args: any) {
@@ -198,6 +199,13 @@ export default function DraftRoom() {
isCommissioner,
} = useLoaderData();
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
+ const {
+ isSupported: notificationsSupported,
+ permissionState: notificationsPermission,
+ enabled: notificationsEnabled,
+ setEnabled: setNotificationsEnabled,
+ sendNotification,
+ } = useDraftNotifications(season.id);
const [picks, setPicks] = useState(draftPicks);
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
const [searchQuery, setSearchQuery] = useState("");
@@ -360,6 +368,36 @@ export default function DraftRoom() {
console.log("Pick made:", data);
setPicks((prev: any) => [...prev, data.pick]);
setCurrentPick(data.nextPickNumber);
+
+ // Send browser notification
+ const pickerName = data.pick?.team?.name || "A team";
+ const participantName = data.pick?.participant?.name || "a participant";
+ const notifTitle = `${season.league.name} Draft`;
+
+ // Check if the next pick is the current user's turn
+ const nextPick = data.nextPickNumber;
+ const totalTeams = draftSlots.length;
+ const nextRound = Math.ceil(nextPick / totalTeams);
+ const isEven = nextRound % 2 === 0;
+ let nextPickInRound = ((nextPick - 1) % totalTeams) + 1;
+ if (isEven) {
+ nextPickInRound = totalTeams - nextPickInRound + 1;
+ }
+ const nextSlot = draftSlots.find(
+ (slot) => slot.draftOrder === nextPickInRound
+ );
+ const isNowMyTurn = !!(
+ userTeam && nextSlot && nextSlot.team.id === userTeam.id
+ );
+
+ if (isNowMyTurn) {
+ sendNotification(notifTitle, `It's your turn to pick!`);
+ } else {
+ sendNotification(
+ notifTitle,
+ `${pickerName} picked ${participantName}`
+ );
+ }
};
const handleTimerUpdate = (data: any) => {
@@ -447,7 +485,7 @@ export default function DraftRoom() {
off("connected-teams-list", handleConnectedTeamsList);
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
};
- }, [on, off, currentPick, userTeam]);
+ }, [on, off, currentPick, userTeam, sendNotification, draftSlots, season.league.name]);
// Persist sidebar collapsed state
useEffect(() => {
@@ -877,6 +915,10 @@ export default function DraftRoom() {
setUserAutodraft({ isEnabled, mode });
}}
onReorder={handleReorderQueue}
+ notificationsEnabled={notificationsEnabled}
+ onNotificationsEnabledChange={setNotificationsEnabled}
+ notificationsSupported={notificationsSupported}
+ notificationsPermission={notificationsPermission}
/>
}
recentPicksSection={