Add browser push notifications toggle to draft page
Adds a Notifications toggle in the draft sidebar (below Autodraft)
that uses the Browser Notification API to alert users when a pick is
made or when it's their turn, while the tab is not focused.
- New useDraftNotifications hook for state, permission, and localStorage
- New NotificationSettings component with Switch toggle
- Fires "It's your turn to pick!" when the next pick is the user's
- Fires "{Team} picked {Player}" for all other picks
- Gracefully hides toggle when Notification API is unavailable
https://claude.ai/code/session_0149MvVUYDY6pFUAV1fL4K69
This commit is contained in:
parent
c368110b3d
commit
c89c1fd973
4 changed files with 187 additions and 1 deletions
43
app/components/NotificationSettings.tsx
Normal file
43
app/components/NotificationSettings.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="border-t pt-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="notifications-switch" className="text-sm font-semibold">
|
||||||
|
Notifications
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="notifications-switch"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={onEnabledChange}
|
||||||
|
disabled={permissionState === "denied"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{permissionState === "denied" && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
Notifications blocked by browser. Update in browser settings to
|
||||||
|
enable.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Badge } from "~/components/ui/badge";
|
import { Badge } from "~/components/ui/badge";
|
||||||
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
import { AutodraftSettings } from "~/components/AutodraftSettings";
|
||||||
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
closestCenter,
|
closestCenter,
|
||||||
|
|
@ -48,6 +49,10 @@ interface QueueSectionProps {
|
||||||
onRemoveFromQueue: (queueId: string) => void;
|
onRemoveFromQueue: (queueId: string) => void;
|
||||||
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void;
|
onAutodraftUpdate: (isEnabled: boolean, mode: "next_pick" | "while_on") => void;
|
||||||
onReorder: (participantIds: string[]) => void;
|
onReorder: (participantIds: string[]) => void;
|
||||||
|
notificationsEnabled: boolean;
|
||||||
|
onNotificationsEnabledChange: (enabled: boolean) => void;
|
||||||
|
notificationsSupported: boolean;
|
||||||
|
notificationsPermission: NotificationPermission | "unsupported";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortable queue item component
|
// Sortable queue item component
|
||||||
|
|
@ -130,6 +135,10 @@ export function QueueSection({
|
||||||
onRemoveFromQueue,
|
onRemoveFromQueue,
|
||||||
onAutodraftUpdate,
|
onAutodraftUpdate,
|
||||||
onReorder,
|
onReorder,
|
||||||
|
notificationsEnabled,
|
||||||
|
onNotificationsEnabledChange,
|
||||||
|
notificationsSupported,
|
||||||
|
notificationsPermission,
|
||||||
}: QueueSectionProps) {
|
}: QueueSectionProps) {
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
|
|
@ -196,6 +205,14 @@ export function QueueSection({
|
||||||
isMyTurn={isMyTurn}
|
isMyTurn={isMyTurn}
|
||||||
onUpdate={onAutodraftUpdate}
|
onUpdate={onAutodraftUpdate}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<NotificationSettings
|
||||||
|
enabled={notificationsEnabled}
|
||||||
|
onEnabledChange={onNotificationsEnabledChange}
|
||||||
|
isSupported={notificationsSupported}
|
||||||
|
permissionState={notificationsPermission}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
app/hooks/useDraftNotifications.ts
Normal file
84
app/hooks/useDraftNotifications.ts
Normal file
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
||||||
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
||||||
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
||||||
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
||||||
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
export async function loader(args: any) {
|
export async function loader(args: any) {
|
||||||
|
|
@ -198,6 +199,13 @@ export default function DraftRoom() {
|
||||||
isCommissioner,
|
isCommissioner,
|
||||||
} = useLoaderData<typeof loader>();
|
} = useLoaderData<typeof loader>();
|
||||||
const { isConnected, connectionError, isReconnecting, on, off } = useDraftSocket(season.id, userTeam?.id);
|
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 [picks, setPicks] = useState(draftPicks);
|
||||||
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
|
@ -360,6 +368,36 @@ export default function DraftRoom() {
|
||||||
console.log("Pick made:", data);
|
console.log("Pick made:", data);
|
||||||
setPicks((prev: any) => [...prev, data.pick]);
|
setPicks((prev: any) => [...prev, data.pick]);
|
||||||
setCurrentPick(data.nextPickNumber);
|
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) => {
|
const handleTimerUpdate = (data: any) => {
|
||||||
|
|
@ -447,7 +485,7 @@ export default function DraftRoom() {
|
||||||
off("connected-teams-list", handleConnectedTeamsList);
|
off("connected-teams-list", handleConnectedTeamsList);
|
||||||
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
||||||
};
|
};
|
||||||
}, [on, off, currentPick, userTeam]);
|
}, [on, off, currentPick, userTeam, sendNotification, draftSlots, season.league.name]);
|
||||||
|
|
||||||
// Persist sidebar collapsed state
|
// Persist sidebar collapsed state
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -877,6 +915,10 @@ export default function DraftRoom() {
|
||||||
setUserAutodraft({ isEnabled, mode });
|
setUserAutodraft({ isEnabled, mode });
|
||||||
}}
|
}}
|
||||||
onReorder={handleReorderQueue}
|
onReorder={handleReorderQueue}
|
||||||
|
notificationsEnabled={notificationsEnabled}
|
||||||
|
onNotificationsEnabledChange={setNotificationsEnabled}
|
||||||
|
notificationsSupported={notificationsSupported}
|
||||||
|
notificationsPermission={notificationsPermission}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
recentPicksSection={
|
recentPicksSection={
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue