Primary fix: setTeamTimers now bails out with `return prev` when the
value hasn't changed, preventing a full DraftRoom re-render on every
1-second timer tick (was 33% of profiler samples).
Memoization: wrap AvailableParticipantsSection, TeamsDraftedGrid,
QueueSection, SidebarRecentPicks, and DraftGridSection in React.memo
so timer ticks don't cascade into heavy components that don't use
timer state.
Stable refs: wrap nine action handlers in useCallback and extract two
inline arrow functions from props objects so memo() comparisons
actually bail out. Memoize the { numFlexPicks } object passed to
TeamsDraftedGrid.
socketVersion: expose an incrementing counter from useDraftSocket so
the socket handler effect re-registers on socket recreation.
Async cleanup: add abort flag + in-flight guard to the visibilitychange
JWT refresh handler to prevent concurrent executions and stale state
updates after unmount. Add abort flag to useDraftNotifications
permissions.query() to prevent dangling onchange if unmounted
mid-promise.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1901 lines
70 KiB
TypeScript
1901 lines
70 KiB
TypeScript
import { useLoaderData, Link, useRevalidator } from "react-router";
|
|
import { useAuth } from "@clerk/react-router";
|
|
import { eq, and, asc, desc, inArray } from "drizzle-orm";
|
|
import { database } from "~/database/context";
|
|
import * as schema from "~/database/schema";
|
|
import { useDraftSocket } from "~/hooks/useDraftSocket";
|
|
import { useCallback, useEffect, useState, useMemo, useRef } from "react";
|
|
import { Card } from "~/components/ui/card";
|
|
import { Badge } from "~/components/ui/badge";
|
|
import { Button } from "~/components/ui/button";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "~/components/ui/dialog";
|
|
import { getAuth } from "@clerk/react-router/server";
|
|
import { getTeamQueue } from "~/models/draft-queue";
|
|
import { buildOwnerMap } from "~/lib/owner-map";
|
|
import { DraftSidebar } from "~/components/DraftSidebar";
|
|
import { TeamsDraftedGrid } from "~/components/draft/TeamsDraftedGrid";
|
|
import { QueueSection } from "~/components/draft/QueueSection";
|
|
import { AvailableParticipantsSection } from "~/components/draft/AvailableParticipantsSection";
|
|
import { SidebarRecentPicks } from "~/components/draft/SidebarRecentPicks";
|
|
import { DraftGridSection } from "~/components/draft/DraftGridSection";
|
|
import { ConnectionOverlay } from "~/components/draft/ConnectionOverlay";
|
|
import { AuthRecoveryOverlay } from "~/components/draft/AuthRecoveryOverlay";
|
|
import { ParticipantSelectionDialog } from "~/components/draft/ParticipantSelectionDialog";
|
|
import { calculateDraftEligibility } from "~/lib/draft-eligibility";
|
|
import { getTeamForPick } from "~/lib/draft-order";
|
|
import { useDraftNotifications } from "~/hooks/useDraftNotifications";
|
|
import { useMediaQuery } from "~/hooks/useMediaQuery";
|
|
import { NotificationSettings } from "~/components/NotificationSettings";
|
|
import { toast } from "sonner";
|
|
import { formatClockTime, getTimerColorClass } from "~/lib/draft-timer";
|
|
import { Users, LayoutGrid, ListChecks, Settings, ListOrdered } from "lucide-react";
|
|
import type { Route } from "./+types/$leagueId.draft.$seasonId";
|
|
|
|
type QueueItem = typeof schema.draftQueue.$inferSelect;
|
|
|
|
const MOBILE_TABS_BASE = [
|
|
{ id: "available" as const, label: "Available", Icon: Users },
|
|
{ id: "board" as const, label: "Board", Icon: LayoutGrid },
|
|
{ id: "roster" as const, label: "Roster", Icon: ListChecks },
|
|
{ id: "controls" as const, label: "Controls", Icon: Settings },
|
|
];
|
|
|
|
const QUEUE_TAB = { id: "queue" as const, label: "Queue", Icon: ListOrdered };
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
const { params } = args;
|
|
const { seasonId, leagueId } = params;
|
|
const { userId } = await getAuth(args);
|
|
|
|
if (!seasonId) {
|
|
throw new Response("Season ID is required", { status: 400 });
|
|
}
|
|
|
|
const db = database();
|
|
|
|
// Get season details
|
|
const season = await db.query.seasons.findFirst({
|
|
where: eq(schema.seasons.id, seasonId),
|
|
with: {
|
|
league: true,
|
|
teams: true,
|
|
},
|
|
});
|
|
|
|
if (!season) {
|
|
throw new Response("Season not found", { status: 404 });
|
|
}
|
|
|
|
// Resolve user's team and commissioner status (null userId means neither)
|
|
const userTeam = userId
|
|
? season.teams.find((team: any) => team.ownerId === userId)
|
|
: undefined;
|
|
|
|
const isCommissioner = userId
|
|
? await db.query.commissioners.findFirst({
|
|
where: and(
|
|
eq(schema.commissioners.leagueId, leagueId),
|
|
eq(schema.commissioners.userId, userId)
|
|
),
|
|
})
|
|
: null;
|
|
|
|
// Access control: if draft board is not public, require authentication and membership
|
|
if (!season.league.isPublicDraftBoard) {
|
|
if (!userId) {
|
|
throw new Response("You must be logged in to view the draft room", {
|
|
status: 401,
|
|
});
|
|
}
|
|
if (!userTeam && !isCommissioner) {
|
|
throw new Response("You do not have access to this draft room", {
|
|
status: 403,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Get draft slots (draft order) - using select instead of query builder
|
|
const draftSlots = await db
|
|
.select({
|
|
id: schema.draftSlots.id,
|
|
draftOrder: schema.draftSlots.draftOrder,
|
|
team: schema.teams,
|
|
})
|
|
.from(schema.draftSlots)
|
|
.innerJoin(schema.teams, eq(schema.draftSlots.teamId, schema.teams.id))
|
|
.where(eq(schema.draftSlots.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftSlots.draftOrder));
|
|
|
|
// Get all draft picks - using select instead of query builder
|
|
const draftPicks = await db
|
|
.select({
|
|
id: schema.draftPicks.id,
|
|
pickNumber: schema.draftPicks.pickNumber,
|
|
round: schema.draftPicks.round,
|
|
pickInRound: schema.draftPicks.pickInRound,
|
|
timeUsed: schema.draftPicks.timeUsed,
|
|
team: schema.teams,
|
|
participant: schema.participants,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.draftPicks)
|
|
.innerJoin(schema.teams, eq(schema.draftPicks.teamId, schema.teams.id))
|
|
.innerJoin(
|
|
schema.participants,
|
|
eq(schema.draftPicks.participantId, schema.participants.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sports,
|
|
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
|
)
|
|
.where(eq(schema.draftPicks.seasonId, seasonId))
|
|
.orderBy(asc(schema.draftPicks.pickNumber));
|
|
|
|
// Get sports seasons for this season
|
|
const seasonSports = await db.query.seasonSports.findMany({
|
|
where: eq(schema.seasonSports.seasonId, seasonId),
|
|
});
|
|
|
|
const sportsSeasonIds = seasonSports.map((ss) => ss.sportsSeasonId);
|
|
|
|
// Get ALL participants (both drafted and undrafted) - sorted by EV desc, then name
|
|
// Filtering will be done on the client side based on the "Show Drafted" toggle
|
|
const availableParticipants = sportsSeasonIds.length > 0
|
|
? await db
|
|
.select({
|
|
id: schema.participants.id,
|
|
name: schema.participants.name,
|
|
sport: schema.sports,
|
|
})
|
|
.from(schema.participants)
|
|
.innerJoin(
|
|
schema.sportsSeasons,
|
|
eq(schema.participants.sportsSeasonId, schema.sportsSeasons.id)
|
|
)
|
|
.innerJoin(
|
|
schema.sports,
|
|
eq(schema.sportsSeasons.sportId, schema.sports.id)
|
|
)
|
|
.where(
|
|
sportsSeasonIds.length === 1
|
|
? eq(schema.participants.sportsSeasonId, sportsSeasonIds[0])
|
|
: inArray(schema.participants.sportsSeasonId, sportsSeasonIds)
|
|
)
|
|
.orderBy(
|
|
desc(schema.participants.expectedValue),
|
|
asc(schema.participants.name)
|
|
)
|
|
: [];
|
|
|
|
// Load user's team queue if they have a team
|
|
const userQueue = userTeam
|
|
? await getTeamQueue(userTeam.id).catch((err) => {
|
|
console.error("[DraftLoader] Failed to load queue, defaulting to empty:", err);
|
|
return [];
|
|
})
|
|
: [];
|
|
|
|
// Load draft timers for all teams
|
|
const timers = await db.query.draftTimers.findMany({
|
|
where: eq(schema.draftTimers.seasonId, seasonId),
|
|
});
|
|
|
|
// Load autodraft settings for all teams
|
|
const autodraftSettings = await db.query.autodraftSettings.findMany({
|
|
where: eq(schema.autodraftSettings.seasonId, seasonId),
|
|
});
|
|
|
|
// Derive user's autodraft settings from the already-loaded list
|
|
const userAutodraftSettings = userTeam
|
|
? (autodraftSettings.find((s) => s.teamId === userTeam.id) ?? null)
|
|
: null;
|
|
|
|
const ownerMap = await buildOwnerMap(draftSlots);
|
|
|
|
return {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner: !!isCommissioner,
|
|
currentUserId: userId,
|
|
numFlexPicks: Math.max(0, season.draftRounds - seasonSports.length),
|
|
ownerMap,
|
|
};
|
|
}
|
|
|
|
export default function DraftRoom() {
|
|
const {
|
|
season,
|
|
draftSlots,
|
|
draftPicks,
|
|
availableParticipants,
|
|
userTeam,
|
|
userQueue,
|
|
timers,
|
|
autodraftSettings,
|
|
userAutodraftSettings,
|
|
isCommissioner,
|
|
currentUserId,
|
|
numFlexPicks,
|
|
ownerMap,
|
|
} = useLoaderData<typeof loader>();
|
|
const { revalidate, state: revalidatorState } = useRevalidator();
|
|
const { userId: clerkUserId, getToken } = useAuth();
|
|
const { isConnected, connectionError, isReconnecting, reconnectCount, socketVersion, on, off } = useDraftSocket(season.id, userTeam?.id);
|
|
const {
|
|
permissionState: notificationsPermission,
|
|
enabled: notificationsEnabled,
|
|
setEnabled: setNotificationsEnabled,
|
|
mode: notificationsMode,
|
|
setMode: setNotificationsMode,
|
|
sendNotification,
|
|
} = useDraftNotifications(season.id, currentUserId ?? "");
|
|
|
|
// Use refs so the socket effect doesn't re-register all handlers when the user
|
|
// changes notification settings (which would change the sendNotification reference).
|
|
const sendNotificationRef = useRef(sendNotification);
|
|
useEffect(() => {
|
|
sendNotificationRef.current = sendNotification;
|
|
}, [sendNotification]);
|
|
const notificationsModeRef = useRef(notificationsMode);
|
|
useEffect(() => {
|
|
notificationsModeRef.current = notificationsMode;
|
|
}, [notificationsMode]);
|
|
|
|
// Track current autodraft state for detecting server-initiated auto-disable
|
|
const [userAutodraft, setUserAutodraft] = useState({
|
|
isEnabled: userAutodraftSettings?.isEnabled || false,
|
|
mode: (userAutodraftSettings?.mode || "next_pick") as "next_pick" | "while_on",
|
|
queueOnly: userAutodraftSettings?.queueOnly || false,
|
|
});
|
|
const userAutodraftRef = useRef(userAutodraft);
|
|
useEffect(() => {
|
|
userAutodraftRef.current = userAutodraft;
|
|
}, [userAutodraft]);
|
|
|
|
// Re-fetch loader data after each reconnect so stale picks/timers are refreshed.
|
|
// Schedule a delayed retry so that if the first attempt fails (common on mobile
|
|
// where the network may not be fully restored yet), we get another chance.
|
|
const revalidationRetryRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
useEffect(() => {
|
|
if (reconnectCount > 0) {
|
|
revalidate();
|
|
// Clear any pending retry from a previous reconnect
|
|
if (revalidationRetryRef.current) {
|
|
clearTimeout(revalidationRetryRef.current);
|
|
}
|
|
revalidationRetryRef.current = setTimeout(() => {
|
|
revalidate();
|
|
revalidationRetryRef.current = null;
|
|
}, 3000);
|
|
}
|
|
return () => {
|
|
if (revalidationRetryRef.current) {
|
|
clearTimeout(revalidationRetryRef.current);
|
|
}
|
|
};
|
|
}, [reconnectCount, revalidate]);
|
|
|
|
// Guard against Clerk JWT expiry race: if the loader ran without a userId (expired
|
|
// short-lived JWT) but the Clerk client SDK has a valid session, revalidate once so
|
|
// the loader re-runs with a fresh token and returns correct userTeam/userQueue data.
|
|
// The ref prevents an infinite loop if the server consistently fails to return a
|
|
// userId (e.g. misconfigured secret key) — we only attempt this recovery once.
|
|
const authRevalidatedRef = useRef(false);
|
|
useEffect(() => {
|
|
if (clerkUserId && !currentUserId && !authRevalidatedRef.current) {
|
|
authRevalidatedRef.current = true;
|
|
revalidate();
|
|
}
|
|
}, [clerkUserId, currentUserId, revalidate]);
|
|
|
|
// Auth session protection: proactively refresh the Clerk JWT to prevent
|
|
// silent logout during long-running draft sessions.
|
|
const [authDegraded, setAuthDegraded] = useState(false);
|
|
|
|
// Fetch wrapper that detects 401 responses and shows the auth recovery
|
|
// overlay instead of letting each handler deal with it individually.
|
|
// Returns null on 401 so callers can bail with `if (!response) return;`.
|
|
const authFetch = useCallback(async (url: string, init?: RequestInit): Promise<Response | null> => {
|
|
const response = await fetch(url, init);
|
|
if (response.status === 401) {
|
|
setAuthDegraded(true);
|
|
return null;
|
|
}
|
|
return response;
|
|
}, []);
|
|
|
|
// Refresh the Clerk JWT when the tab becomes visible again. Browsers
|
|
// throttle/suspend intervals in background tabs, so the SDK's automatic
|
|
// 60-second refresh may not have run. Getting a fresh token here prevents
|
|
// the next user action from hitting a 401.
|
|
useEffect(() => {
|
|
// Nothing to do once auth is fully degraded — avoid re-registering a
|
|
// listener that would immediately exit on every future visibility change.
|
|
if (authDegraded) return;
|
|
|
|
let aborted = false;
|
|
let inFlight = false;
|
|
|
|
const handleVisibilityChange = async () => {
|
|
// Ignore hide events, and skip if another invocation is already running
|
|
// (rapid tab switching) or this effect has been cleaned up.
|
|
if (document.visibilityState !== "visible" || !clerkUserId || inFlight) return;
|
|
inFlight = true;
|
|
try {
|
|
const token = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (token === null) {
|
|
// Retry once after a short delay — the network may still be waking up.
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
if (aborted) return;
|
|
const retryToken = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (retryToken === null) setAuthDegraded(true);
|
|
}
|
|
} catch {
|
|
try {
|
|
await new Promise((r) => setTimeout(r, 1000));
|
|
if (aborted) return;
|
|
const retryToken = await getToken({ skipCache: true });
|
|
if (aborted) return;
|
|
if (retryToken === null) setAuthDegraded(true);
|
|
} catch {
|
|
if (!aborted) setAuthDegraded(true);
|
|
}
|
|
} finally {
|
|
inFlight = false;
|
|
}
|
|
};
|
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
return () => {
|
|
aborted = true;
|
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
};
|
|
}, [clerkUserId, getToken, authDegraded]);
|
|
|
|
// Track revalidation lifecycle to sync local state from fresh loader data.
|
|
//
|
|
// The problem: `picks` and `currentPick` are local state initialized once
|
|
// from useLoaderData(). When revalidate() runs after a reconnect, React
|
|
// Router re-fetches `draftPicks`/`season` — but those updates never flow
|
|
// back into the local useState copies automatically.
|
|
//
|
|
// Race condition: a `pick-made` socket event that arrives *during* the
|
|
// revalidation window would normally be applied to the stale prev state and
|
|
// then overwritten when we sync from the DB snapshot. We prevent that by
|
|
// buffering live picks while revalidation is in flight and merging them
|
|
// (deduplicated by ID) after the DB snapshot lands.
|
|
const revalidatorStateRef = useRef(revalidatorState);
|
|
const isRevalidatingRef = useRef(false);
|
|
const pendingPicksDuringRevalidationRef = useRef<any[]>([]);
|
|
// Track the draftPicks reference when revalidation starts. If it hasn't
|
|
// changed by the time revalidation completes, the loader didn't return new
|
|
// data (e.g. network failure) and we should NOT overwrite state that may
|
|
// have been freshly set by draft-state-sync.
|
|
const draftPicksAtRevalidationStartRef = useRef(draftPicks);
|
|
|
|
useEffect(() => {
|
|
const prev = revalidatorStateRef.current;
|
|
revalidatorStateRef.current = revalidatorState;
|
|
|
|
if (prev !== "loading" && revalidatorState === "loading") {
|
|
// Revalidation just started — buffer any live picks to avoid loss
|
|
isRevalidatingRef.current = true;
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
draftPicksAtRevalidationStartRef.current = draftPicks;
|
|
} else if (prev === "loading" && revalidatorState === "idle") {
|
|
// Revalidation just completed — merge DB snapshot with buffered picks
|
|
isRevalidatingRef.current = false;
|
|
|
|
// If draftPicks is the same reference as when revalidation started, the
|
|
// loader didn't actually update (likely a failed fetch). Skip the sync
|
|
// to avoid overwriting fresh draft-state-sync data with stale state.
|
|
if (draftPicks === draftPicksAtRevalidationStartRef.current) {
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
return;
|
|
}
|
|
|
|
const dbPickIds = new Set(draftPicks.map((p: any) => p.id));
|
|
const missedPicks = pendingPicksDuringRevalidationRef.current.filter(
|
|
(p: any) => !dbPickIds.has(p.id)
|
|
);
|
|
pendingPicksDuringRevalidationRef.current = [];
|
|
|
|
setPicks([...draftPicks, ...missedPicks]);
|
|
setCurrentPick(season.currentPickNumber || 1);
|
|
setIsPaused(season.draftPaused || false);
|
|
setIsDraftComplete(
|
|
season.status === "active" || season.status === "completed"
|
|
);
|
|
// Sync the queue — participants drafted while we were away will have
|
|
// been removed server-side, but we never received those
|
|
// `participant-removed-from-queues` events. The loader re-fetches
|
|
// userQueue fresh on every revalidation so we can trust it here.
|
|
setQueue(userQueue);
|
|
// Sync timers — the server-side timer state may have changed while
|
|
// we were disconnected (time bank adjustments, new timers, etc.)
|
|
if (timers.length > 0) {
|
|
setTeamTimers((prev) => {
|
|
const updated = { ...prev };
|
|
timers.forEach((timer: any) => {
|
|
updated[timer.teamId] = timer.timeRemaining;
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
// Sync autodraft settings — teams may have toggled autodraft while
|
|
// we were away.
|
|
setAutodraftStatus(() => {
|
|
const status: Record<string, boolean> = {};
|
|
autodraftSettings.forEach((setting: any) => {
|
|
status[setting.teamId] = setting.isEnabled;
|
|
});
|
|
return status;
|
|
});
|
|
}
|
|
}, [revalidatorState, draftPicks, season, userQueue, timers, autodraftSettings]);
|
|
|
|
const [picks, setPicks] = useState(draftPicks);
|
|
const [currentPick, setCurrentPick] = useState(season.currentPickNumber || 1);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [hideDrafted, setHideDrafted] = useState(true);
|
|
const [hideIneligible, setHideIneligible] = useState(true);
|
|
const [hideCompletedSports, setHideCompletedSports] = useState(false);
|
|
const [sportFilters, setSportFilters] = useState<string[]>([]);
|
|
const [queue, setQueue] = useState<QueueItem[]>(userQueue);
|
|
const [isPaused, setIsPaused] = useState(season.draftPaused || false);
|
|
const [isDraftComplete, setIsDraftComplete] = useState(
|
|
season.status === "active" || season.status === "completed"
|
|
);
|
|
|
|
// Sidebar and tabs state
|
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(() => {
|
|
if (typeof window === "undefined") return false;
|
|
const stored = localStorage.getItem("draftSidebarCollapsed");
|
|
return stored ? JSON.parse(stored) : false;
|
|
});
|
|
const [activeTab, setActiveTab] = useState<"participants" | "board" | "teams">("board");
|
|
const [mobileTab, setMobileTab] = useState<"available" | "queue" | "board" | "roster" | "controls">(
|
|
!userTeam && isCommissioner ? "board" : "available"
|
|
);
|
|
const isMobile = useMediaQuery("(max-width: 767px)");
|
|
|
|
// Track autodraft status for all teams
|
|
const [autodraftStatus, setAutodraftStatus] = useState<Record<string, boolean>>(() => {
|
|
const status: Record<string, boolean> = {};
|
|
autodraftSettings.forEach((setting: any) => {
|
|
status[setting.teamId] = setting.isEnabled;
|
|
});
|
|
return status;
|
|
});
|
|
|
|
// Track connected teams
|
|
const [connectedTeams, setConnectedTeams] = useState<Set<string>>(new Set());
|
|
|
|
const [teamTimers, setTeamTimers] = useState<Record<string, number>>(() => {
|
|
// Initialize with timers from loader data, or use initial time if draft hasn't started
|
|
const timersMap: Record<string, number> = {};
|
|
const initialTime = season.draftInitialTime || 120;
|
|
|
|
if (timers.length > 0) {
|
|
// Draft has started, use actual timers
|
|
timers.forEach((timer: any) => {
|
|
timersMap[timer.teamId] = timer.timeRemaining;
|
|
});
|
|
} else {
|
|
// Draft hasn't started, show initial time for all teams
|
|
draftSlots.forEach((slot) => {
|
|
timersMap[slot.team.id] = initialTime;
|
|
});
|
|
}
|
|
|
|
return timersMap;
|
|
});
|
|
const [forcePickDialogOpen, setForcePickDialogOpen] = useState(false);
|
|
const [selectedPickSlot, setSelectedPickSlot] = useState<{
|
|
pickNumber: number;
|
|
teamId: string;
|
|
} | null>(null);
|
|
// Replace pick dialog state
|
|
const [replacePickDialogOpen, setReplacePickDialogOpen] = useState(false);
|
|
const [replacePickSlot, setReplacePickSlot] = useState<{
|
|
pickNumber: number;
|
|
teamId: string;
|
|
oldParticipantId: string;
|
|
} | null>(null);
|
|
|
|
// Rollback confirm dialog state
|
|
const [rollbackConfirmOpen, setRollbackConfirmOpen] = useState(false);
|
|
const [rollbackPickNumber, setRollbackPickNumber] = useState<number | null>(null);
|
|
const [isRollingBack, setIsRollingBack] = useState(false);
|
|
|
|
// Time bank adjustment dialog state
|
|
const [timeBankDialogOpen, setTimeBankDialogOpen] = useState(false);
|
|
const [timeBankTeamId, setTimeBankTeamId] = useState<string | null>(null);
|
|
const [timeBankAmount, setTimeBankAmount] = useState("1");
|
|
const [timeBankUnit, setTimeBankUnit] = useState<"seconds" | "minutes" | "hours">("minutes");
|
|
const [timeBankDirection, setTimeBankDirection] = useState<"add" | "remove">("add");
|
|
const [isAdjustingTimeBank, setIsAdjustingTimeBank] = useState(false);
|
|
|
|
// Shared transforms for eligibility calculations
|
|
const transformedPicks = useMemo(
|
|
() =>
|
|
picks.map((p: any) => ({
|
|
teamId: p.team.id,
|
|
participant: {
|
|
id: p.participant.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
},
|
|
})),
|
|
[picks]
|
|
);
|
|
|
|
const transformedParticipants = useMemo(
|
|
() =>
|
|
availableParticipants.map((p: any) => ({
|
|
id: p.id,
|
|
sport: { id: p.sport.id, name: p.sport.name },
|
|
})),
|
|
[availableParticipants]
|
|
);
|
|
|
|
const seasonSportsData = useMemo(() => {
|
|
const sportsMap = new Map<string, { id: string; name: string }>();
|
|
availableParticipants.forEach((p) => {
|
|
if (!sportsMap.has(p.sport.id)) {
|
|
sportsMap.set(p.sport.id, { id: p.sport.id, name: p.sport.name });
|
|
}
|
|
});
|
|
return Array.from(sportsMap.values()).sort((a, b) =>
|
|
a.name.localeCompare(b.name)
|
|
);
|
|
}, [availableParticipants]);
|
|
|
|
// Calculate draft eligibility for current user's team
|
|
const eligibility = useMemo(() => {
|
|
if (!userTeam) return null;
|
|
const userTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === userTeam.id
|
|
);
|
|
return calculateDraftEligibility(
|
|
userTeam.id,
|
|
userTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [userTeam, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Calculate eligibility for force manual pick dialog (selected team)
|
|
const forcePickEligibility = useMemo(() => {
|
|
if (!selectedPickSlot) return null;
|
|
const selectedTeamPicks = transformedPicks.filter(
|
|
(p: any) => p.teamId === selectedPickSlot.teamId
|
|
);
|
|
return calculateDraftEligibility(
|
|
selectedPickSlot.teamId,
|
|
selectedTeamPicks,
|
|
transformedPicks,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [selectedPickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Calculate eligibility for replace pick dialog — exclude the old pick so its slot is free
|
|
const replacePickEligibility = useMemo(() => {
|
|
if (!replacePickSlot) return null;
|
|
const allPicksExcluding = transformedPicks.filter(
|
|
(p: any) => p.participant.id !== replacePickSlot.oldParticipantId
|
|
);
|
|
const selectedTeamPicks = allPicksExcluding.filter(
|
|
(p: any) => p.teamId === replacePickSlot.teamId
|
|
);
|
|
return calculateDraftEligibility(
|
|
replacePickSlot.teamId,
|
|
selectedTeamPicks,
|
|
allPicksExcluding,
|
|
transformedParticipants,
|
|
seasonSportsData,
|
|
season.draftRounds,
|
|
season.teams
|
|
);
|
|
}, [replacePickSlot, transformedPicks, transformedParticipants, seasonSportsData, season]);
|
|
|
|
// Listen for new picks from other users
|
|
useEffect(() => {
|
|
const handlePickMade = (data: any) => {
|
|
if (isRevalidatingRef.current) {
|
|
// Buffer this pick; the revalidation-complete handler will merge it
|
|
// into the DB snapshot so it isn't silently dropped.
|
|
// Don't update currentPick either — the sync effect will set it from
|
|
// fresh season.currentPickNumber once loading → idle, keeping the
|
|
// pick counter and the picks list in sync with each other.
|
|
pendingPicksDuringRevalidationRef.current.push(data.pick);
|
|
} else {
|
|
setPicks((prev: any) => [...prev, data.pick]);
|
|
setCurrentPick(data.nextPickNumber);
|
|
}
|
|
|
|
// No meaningful "next turn" notification once the draft is over
|
|
if (data.isDraftComplete) return;
|
|
|
|
const notifTitle = `${season.league.name} Draft`;
|
|
const nextSlot = getTeamForPick(data.nextPickNumber, draftSlots);
|
|
const isNowMyTurn = !!(
|
|
userTeam && nextSlot && nextSlot.team.id === userTeam.id
|
|
);
|
|
|
|
if (isNowMyTurn) {
|
|
sendNotificationRef.current(notifTitle, `It's your turn to pick!`);
|
|
} else if (
|
|
notificationsModeRef.current === "all_picks" &&
|
|
data.pick?.team?.id !== userTeam?.id
|
|
) {
|
|
// Only notify about other teams' picks, not the user's own
|
|
const pickerName = data.pick?.team?.name || "A team";
|
|
const participantName = data.pick?.participant?.name || "a participant";
|
|
sendNotificationRef.current(
|
|
notifTitle,
|
|
`${pickerName} picked ${participantName}`
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleTimerUpdate = (data: any) => {
|
|
// Bail out without creating a new object reference if nothing changed.
|
|
// Without this guard the spread `{ ...prev }` always returns a new object,
|
|
// causing a full re-render of the DraftRoom tree on every 1-second tick.
|
|
setTeamTimers((prev) => {
|
|
if (prev[data.teamId] === data.timeRemaining) return prev;
|
|
return { ...prev, [data.teamId]: data.timeRemaining };
|
|
});
|
|
// Also sync the current pick number from the server. This is critical
|
|
// for mobile reconnection: timer-update events arrive every second, so
|
|
// this ensures currentPick is correct within 1s of reconnecting — even
|
|
// if the HTTP revalidation hasn't completed yet.
|
|
// React's useState already bails out when the new value equals the old
|
|
// one (Object.is comparison on primitives), so no manual guard needed.
|
|
if (data.currentPickNumber != null) {
|
|
setCurrentPick(data.currentPickNumber);
|
|
}
|
|
};
|
|
|
|
const handleDraftPaused = () => {
|
|
setIsPaused(true);
|
|
};
|
|
|
|
const handleDraftResumed = () => {
|
|
setIsPaused(false);
|
|
};
|
|
|
|
const handleDraftCompleted = () => {
|
|
setIsDraftComplete(true);
|
|
};
|
|
|
|
const handleAutodraftUpdated = (data: { teamId: string; isEnabled: boolean; mode: "next_pick" | "while_on"; queueOnly: boolean }) => {
|
|
setAutodraftStatus((prev) => ({
|
|
...prev,
|
|
[data.teamId]: data.isEnabled,
|
|
}));
|
|
|
|
// Update user's local state if it's their team
|
|
if (userTeam && data.teamId === userTeam.id) {
|
|
// Detect server-side auto-disable: was enabled with queueOnly, now disabled
|
|
const prev = userAutodraftRef.current;
|
|
if (!data.isEnabled && prev.isEnabled && prev.queueOnly) {
|
|
toast.info("Autodraft disabled — your queue is empty");
|
|
}
|
|
|
|
setUserAutodraft({
|
|
isEnabled: data.isEnabled,
|
|
mode: data.mode,
|
|
queueOnly: data.queueOnly,
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleTeamConnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => new Set(prev).add(data.teamId));
|
|
};
|
|
|
|
const handleTeamDisconnected = (data: { teamId: string }) => {
|
|
setConnectedTeams((prev) => {
|
|
const newSet = new Set(prev);
|
|
newSet.delete(data.teamId);
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const handleConnectedTeamsList = (data: { teamIds: string[] }) => {
|
|
setConnectedTeams(new Set(data.teamIds));
|
|
};
|
|
|
|
const handleParticipantRemovedFromQueues = (data: { participantId: string }) => {
|
|
setQueue((prev: any) =>
|
|
prev.filter((item: any) => item.participantId !== data.participantId)
|
|
);
|
|
};
|
|
|
|
const handlePickReplaced = (data: any) => {
|
|
setPicks((prev: any) =>
|
|
prev.map((p: any) => (p.pickNumber === data.pickNumber ? data.pick : p))
|
|
);
|
|
};
|
|
|
|
const handleDraftRolledBack = (data: any) => {
|
|
setPicks((prev: any) =>
|
|
prev.filter((p: any) => p.pickNumber < data.pickNumber)
|
|
);
|
|
setCurrentPick(data.pickNumber);
|
|
setIsDraftComplete(false);
|
|
setIsPaused(false);
|
|
};
|
|
|
|
// Full state sync sent by the server on join-draft. Provides an immediate,
|
|
// socket-based path to bring the client up to date — critical for mobile
|
|
// reconnection where the HTTP revalidation may be delayed or fail entirely.
|
|
const handleDraftStateSync = (data: any) => {
|
|
// If a revalidation is in-flight, let it complete and handle the merge.
|
|
// The revalidation path already buffers picks and deduplicates — layering
|
|
// this on top would risk conflicts. But if NOT revalidating, apply the
|
|
// server snapshot directly.
|
|
if (!isRevalidatingRef.current) {
|
|
setPicks(data.picks);
|
|
setCurrentPick(data.currentPickNumber);
|
|
setIsPaused(data.isPaused);
|
|
setIsDraftComplete(
|
|
data.status === "active" || data.status === "completed"
|
|
);
|
|
if (data.timers) {
|
|
setTeamTimers((prev) => {
|
|
const updated = { ...prev };
|
|
data.timers.forEach((t: any) => {
|
|
updated[t.teamId] = t.timeRemaining;
|
|
});
|
|
return updated;
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
on("pick-made", handlePickMade);
|
|
on("timer-update", handleTimerUpdate);
|
|
on("draft-paused", handleDraftPaused);
|
|
on("draft-resumed", handleDraftResumed);
|
|
on("draft-completed", handleDraftCompleted);
|
|
on("autodraft-updated", handleAutodraftUpdated);
|
|
on("team-connected", handleTeamConnected);
|
|
on("team-disconnected", handleTeamDisconnected);
|
|
on("connected-teams-list", handleConnectedTeamsList);
|
|
on("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
|
on("pick-replaced", handlePickReplaced);
|
|
on("draft-rolled-back", handleDraftRolledBack);
|
|
on("draft-state-sync", handleDraftStateSync);
|
|
|
|
return () => {
|
|
off("pick-made", handlePickMade);
|
|
off("timer-update", handleTimerUpdate);
|
|
off("draft-paused", handleDraftPaused);
|
|
off("draft-resumed", handleDraftResumed);
|
|
off("draft-completed", handleDraftCompleted);
|
|
off("autodraft-updated", handleAutodraftUpdated);
|
|
off("team-connected", handleTeamConnected);
|
|
off("team-disconnected", handleTeamDisconnected);
|
|
off("connected-teams-list", handleConnectedTeamsList);
|
|
off("participant-removed-from-queues", handleParticipantRemovedFromQueues);
|
|
off("pick-replaced", handlePickReplaced);
|
|
off("draft-rolled-back", handleDraftRolledBack);
|
|
off("draft-state-sync", handleDraftStateSync);
|
|
};
|
|
// `on`/`off` are stable useCallback refs. `socketVersion` increments whenever
|
|
// useDraftSocket creates a new socket instance, ensuring this effect re-runs
|
|
// and re-registers all handlers on the fresh socket.
|
|
// `draftSlots`, `userTeam`, and `season.league.name` are intentionally omitted:
|
|
// they come from useLoaderData and are immutable for the lifetime of the page load.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [on, off, socketVersion]);
|
|
|
|
// Persist sidebar collapsed state
|
|
useEffect(() => {
|
|
if (typeof window !== "undefined") {
|
|
localStorage.setItem("draftSidebarCollapsed", JSON.stringify(sidebarCollapsed));
|
|
}
|
|
}, [sidebarCollapsed]);
|
|
|
|
// Queue handlers
|
|
const handleAddToQueue = useCallback(async (participantId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Check if participant is already in queue (client-side)
|
|
const alreadyInQueue = queue.some((item) => item.participantId === participantId);
|
|
if (alreadyInQueue) {
|
|
toast.error("This participant is already in your queue");
|
|
return;
|
|
}
|
|
|
|
// Optimistic update - create a temporary queue item
|
|
const now = new Date();
|
|
const tempQueueItem: QueueItem = {
|
|
id: `temp-${Date.now()}`,
|
|
participantId,
|
|
queuePosition: queue.length + 1,
|
|
seasonId: season.id,
|
|
teamId: userTeam.id,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
// Immediately update the UI
|
|
setQueue((prev) => [...prev, tempQueueItem]);
|
|
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
try {
|
|
const response = await authFetch("/api/queue/add", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id)); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
// Replace temp item with real item from server
|
|
setQueue((prev) =>
|
|
prev.map((item) =>
|
|
item.id === tempQueueItem.id ? data.queueItem : item
|
|
)
|
|
);
|
|
} else {
|
|
// Revert the optimistic update on error
|
|
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to add to queue");
|
|
}
|
|
} catch (error) {
|
|
// Revert the optimistic update on network error
|
|
setQueue((prev) => prev.filter((item) => item.id !== tempQueueItem.id));
|
|
toast.error("Network error - failed to add to queue");
|
|
}
|
|
// queue is read directly (not via functional updater) for the duplicate check and
|
|
// optimistic position, so it must be a dep. This is fine: AvailableParticipantsSection
|
|
// already re-renders when queue changes (it receives queue as a prop).
|
|
}, [userTeam, queue, season.id, authFetch]);
|
|
|
|
const handleRemoveFromQueue = useCallback(async (queueId: string) => {
|
|
if (!userTeam) return;
|
|
|
|
// Optimistically remove the item immediately
|
|
let previousQueue: QueueItem[] = [];
|
|
setQueue((prev) => {
|
|
previousQueue = prev;
|
|
return prev.filter((item) => item.id !== queueId);
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append("queueId", queueId);
|
|
formData.append("teamId", userTeam.id);
|
|
|
|
try {
|
|
const response = await authFetch("/api/queue/remove", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue(previousQueue); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
setQueue(previousQueue);
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to remove from queue");
|
|
}
|
|
} catch {
|
|
setQueue(previousQueue);
|
|
toast.error("Network error - failed to remove from queue");
|
|
}
|
|
}, [userTeam, authFetch]);
|
|
|
|
const handleReorderQueue = useCallback(async (participantIds: string[]) => {
|
|
if (!userTeam) return;
|
|
|
|
// Optimistically reorder the queue immediately
|
|
let previousQueue: QueueItem[] = [];
|
|
setQueue((prev) => {
|
|
previousQueue = prev;
|
|
return participantIds
|
|
.map((pid) => prev.find((item) => item.participantId === pid))
|
|
.filter((item): item is QueueItem => item !== undefined);
|
|
});
|
|
|
|
const formData = new FormData();
|
|
formData.append("teamId", userTeam.id);
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantIds", JSON.stringify(participantIds));
|
|
|
|
try {
|
|
const response = await authFetch("/api/queue/reorder", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) { setQueue(previousQueue); return; }
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setQueue(data.queue);
|
|
} else {
|
|
const error = await response.json();
|
|
setQueue(previousQueue);
|
|
toast.error(error.error || "Failed to reorder queue");
|
|
}
|
|
} catch {
|
|
setQueue(previousQueue);
|
|
toast.error("Network error - failed to reorder queue");
|
|
}
|
|
}, [userTeam, season.id, authFetch]);
|
|
|
|
const handleStartDraft = async () => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
const response = await authFetch("/api/draft/start", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
revalidate();
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to start draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to start draft");
|
|
}
|
|
};
|
|
|
|
const handlePauseDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await authFetch("/api/draft/pause", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setIsPaused(true);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to pause draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to pause draft");
|
|
}
|
|
};
|
|
|
|
const handleResumeDraft = async () => {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
|
|
try {
|
|
const response = await authFetch("/api/draft/resume", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setIsPaused(false);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to resume draft");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to resume draft");
|
|
}
|
|
};
|
|
|
|
const handleMakePick = useCallback(async (participantId: string) => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("participantId", participantId);
|
|
|
|
const response = await authFetch("/api/draft/make-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to make pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to make pick");
|
|
}
|
|
}, [season.id, authFetch]);
|
|
|
|
const handleForceAutopick = useCallback(async (pickNumber: number, teamId: string) => {
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", teamId);
|
|
formData.append("pickNumber", pickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/force-autopick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (!response.ok) {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force autopick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to force autopick");
|
|
}
|
|
}, [season.id, authFetch]);
|
|
|
|
const handleForceManualPick = async (participantId: string) => {
|
|
if (!selectedPickSlot) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", selectedPickSlot.teamId);
|
|
formData.append("participantId", participantId);
|
|
formData.append("pickNumber", selectedPickSlot.pickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/force-manual-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setForcePickDialogOpen(false);
|
|
setSelectedPickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to force manual pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to force manual pick");
|
|
}
|
|
};
|
|
|
|
const handleReplacePickOpen = useCallback((pickNumber: number, teamId: string) => {
|
|
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
|
|
if (!pick) {
|
|
toast.error("Pick not found — try refreshing the page");
|
|
return;
|
|
}
|
|
setReplacePickSlot({ pickNumber, teamId, oldParticipantId: pick.participant.id });
|
|
setReplacePickDialogOpen(true);
|
|
// picks is read directly to find the pick being replaced.
|
|
}, [picks]);
|
|
|
|
const handleReplacePick = async (participantId: string) => {
|
|
if (!replacePickSlot) return;
|
|
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", replacePickSlot.teamId);
|
|
formData.append("participantId", participantId);
|
|
formData.append("pickNumber", replacePickSlot.pickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/replace-pick", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setReplacePickDialogOpen(false);
|
|
setReplacePickSlot(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to replace pick");
|
|
}
|
|
} catch {
|
|
toast.error("Network error - failed to replace pick");
|
|
}
|
|
};
|
|
|
|
const handleRollbackToPick = useCallback((pickNumber: number) => {
|
|
setRollbackPickNumber(pickNumber);
|
|
setRollbackConfirmOpen(true);
|
|
}, []);
|
|
|
|
const handleConfirmRollback = async () => {
|
|
if (!rollbackPickNumber || isRollingBack) return;
|
|
|
|
setIsRollingBack(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("pickNumber", rollbackPickNumber.toString());
|
|
|
|
const response = await authFetch("/api/draft/rollback", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
|
|
setIsRollingBack(false);
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
setRollbackConfirmOpen(false);
|
|
setRollbackPickNumber(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to roll back draft");
|
|
}
|
|
} catch {
|
|
setIsRollingBack(false);
|
|
toast.error("Network error - failed to roll back draft");
|
|
}
|
|
};
|
|
|
|
const handleAdjustTimeBankOpen = useCallback((teamId: string) => {
|
|
setTimeBankTeamId(teamId);
|
|
setTimeBankAmount("1");
|
|
setTimeBankUnit("minutes");
|
|
setTimeBankDirection("add");
|
|
setTimeBankDialogOpen(true);
|
|
}, []);
|
|
|
|
const handleConfirmAdjustTimeBank = async () => {
|
|
if (!timeBankTeamId || isAdjustingTimeBank) return;
|
|
|
|
const amount = parseFloat(timeBankAmount);
|
|
if (isNaN(amount) || amount <= 0) {
|
|
toast.error("Please enter a valid positive amount");
|
|
return;
|
|
}
|
|
|
|
const unitMultipliers = { seconds: 1, minutes: 60, hours: 3600 };
|
|
const totalSeconds = Math.round(amount * unitMultipliers[timeBankUnit]);
|
|
|
|
if (totalSeconds === 0) {
|
|
toast.error("Adjustment rounds to 0 seconds — please enter a larger value");
|
|
return;
|
|
}
|
|
|
|
const adjustment = timeBankDirection === "add" ? totalSeconds : -totalSeconds;
|
|
|
|
setIsAdjustingTimeBank(true);
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append("seasonId", season.id);
|
|
formData.append("teamId", timeBankTeamId);
|
|
formData.append("adjustment", adjustment.toString());
|
|
|
|
const response = await authFetch("/api/draft/adjust-time-bank", {
|
|
method: "POST",
|
|
body: formData,
|
|
});
|
|
if (!response) return;
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setTeamTimers((prev) => ({ ...prev, [timeBankTeamId]: data.timeRemaining }));
|
|
const teamName = draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name;
|
|
toast.success(`Time bank adjusted for ${teamName}`);
|
|
setTimeBankDialogOpen(false);
|
|
setTimeBankTeamId(null);
|
|
} else {
|
|
const error = await response.json();
|
|
toast.error(error.error || "Failed to adjust time bank");
|
|
}
|
|
} catch {
|
|
toast.error("Network error — failed to adjust time bank");
|
|
} finally {
|
|
setIsAdjustingTimeBank(false);
|
|
}
|
|
};
|
|
|
|
const handleForceManualPickOpen = useCallback((pickNumber: number, teamId: string) => {
|
|
setSelectedPickSlot({ pickNumber, teamId });
|
|
setForcePickDialogOpen(true);
|
|
}, []);
|
|
|
|
// Calculate current round
|
|
const currentRound = draftSlots.length > 0 ? Math.ceil(currentPick / draftSlots.length) : 1;
|
|
|
|
// Determine whose turn it is
|
|
const currentDraftSlot = useMemo(
|
|
() => getTeamForPick(currentPick, draftSlots),
|
|
[currentPick, draftSlots]
|
|
);
|
|
const isMyTurn = !!(
|
|
userTeam && currentDraftSlot && currentDraftSlot.team.id === userTeam.id
|
|
);
|
|
const canPick = isMyTurn && season.status === "draft" && !isPaused; // Only team owner on their turn when draft is active
|
|
|
|
const currentDraftSlotOwnerName = currentDraftSlot ? ownerMap[currentDraftSlot.team.id] : undefined;
|
|
const currentClockTime = currentDraftSlot ? teamTimers[currentDraftSlot.team.id] : undefined;
|
|
const currentClockColor = getTimerColorClass(currentClockTime);
|
|
|
|
// Generate snake draft grid structure
|
|
const draftGrid = useMemo(() => {
|
|
const teamCount = draftSlots.length;
|
|
const rounds = season.draftRounds;
|
|
const grid: Array<
|
|
Array<{
|
|
pickNumber: number;
|
|
round: number;
|
|
pickInRound: number;
|
|
teamId: string;
|
|
pick?: any;
|
|
}>
|
|
> = [];
|
|
|
|
for (let round = 1; round <= rounds; round++) {
|
|
const roundPicks = [];
|
|
const isOddRound = round % 2 === 1;
|
|
|
|
for (let i = 0; i < teamCount; i++) {
|
|
const pickInRound = i + 1;
|
|
const teamIndex = isOddRound ? i : teamCount - 1 - i;
|
|
const pickNumber = (round - 1) * teamCount + pickInRound;
|
|
const teamId = draftSlots[teamIndex]?.team.id;
|
|
|
|
const pick = picks.find((p: any) => p.pickNumber === pickNumber);
|
|
|
|
roundPicks.push({ pickNumber, round, pickInRound, teamId, pick });
|
|
}
|
|
grid.push(roundPicks);
|
|
}
|
|
|
|
return grid;
|
|
}, [picks, draftSlots, season.draftRounds]);
|
|
|
|
// Get drafted participant IDs for filtering
|
|
const draftedParticipantIds = useMemo(
|
|
() => new Set(picks.map((p: any) => p.participant.id)),
|
|
[picks]
|
|
);
|
|
|
|
// Sport IDs where the current user's team has already made at least one pick
|
|
const userDraftedSportIds = useMemo(() => {
|
|
if (!userTeam) return new Set<string>();
|
|
return new Set(
|
|
picks
|
|
.filter((p: any) => p.team.id === userTeam.id)
|
|
.map((p: any) => p.sport.id)
|
|
);
|
|
}, [picks, userTeam]);
|
|
|
|
// Sport names where the current user's team has already made at least one pick
|
|
const userDraftedSportNames = useMemo(() => {
|
|
if (!userTeam) return new Set<string>();
|
|
return new Set(
|
|
picks
|
|
.filter((p: any) => p.team.id === userTeam.id)
|
|
.map((p: any) => p.sport.name)
|
|
);
|
|
}, [picks, userTeam]);
|
|
|
|
// Get unique sports for filter dropdown
|
|
const uniqueSports = useMemo(
|
|
() =>
|
|
Array.from(
|
|
new Set(availableParticipants.map((p: any) => p.sport.name))
|
|
).sort(),
|
|
[availableParticipants]
|
|
);
|
|
|
|
// Filter participants based on search, sport, drafted status, and eligibility
|
|
const filteredParticipants = useMemo(() => {
|
|
const sportFilterSet = new Set(sportFilters);
|
|
return availableParticipants.filter((participant: any) => {
|
|
// Drafted filter - hide drafted participants by default
|
|
if (hideDrafted && draftedParticipantIds.has(participant.id)) {
|
|
return false;
|
|
}
|
|
// Eligibility filter - hide ineligible participants by default (if user has a team)
|
|
if (hideIneligible && eligibility) {
|
|
const isEligible = eligibility.eligibleSportIds.has(participant.sport.id);
|
|
if (!isEligible) {
|
|
return false;
|
|
}
|
|
}
|
|
// Hide completed sports filter - hide participants from sports already drafted by user's team
|
|
if (hideCompletedSports && userDraftedSportIds.has(participant.sport.id)) {
|
|
return false;
|
|
}
|
|
// Search filter
|
|
if (
|
|
searchQuery &&
|
|
!participant.name.toLowerCase().includes(searchQuery.toLowerCase())
|
|
) {
|
|
return false;
|
|
}
|
|
// Sport filter
|
|
if (sportFilterSet.size > 0 && !sportFilterSet.has(participant.sport.name)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
});
|
|
}, [availableParticipants, hideDrafted, hideIneligible, hideCompletedSports, eligibility, draftedParticipantIds, userDraftedSportIds, searchQuery, sportFilters]);
|
|
|
|
// Shared component props — defined once to avoid duplication between desktop and mobile layouts
|
|
const handleHideCompletedSportsChange = useCallback((hide: boolean) => {
|
|
setHideCompletedSports(hide);
|
|
if (hide) {
|
|
setSportFilters((prev) => prev.filter((s) => !userDraftedSportNames.has(s)));
|
|
}
|
|
}, [userDraftedSportNames]);
|
|
|
|
const availableParticipantsSectionProps = {
|
|
participants: filteredParticipants,
|
|
searchQuery,
|
|
sportFilters,
|
|
hideDrafted,
|
|
hideIneligible,
|
|
uniqueSports,
|
|
draftedParticipantIds,
|
|
queue,
|
|
eligibility,
|
|
canPick,
|
|
hasTeam: !!userTeam,
|
|
onSearchChange: setSearchQuery,
|
|
onSportFiltersChange: setSportFilters,
|
|
onHideDraftedChange: setHideDrafted,
|
|
onHideIneligibleChange: setHideIneligible,
|
|
hideCompletedSports,
|
|
userDraftedSportNames,
|
|
onHideCompletedSportsChange: handleHideCompletedSportsChange,
|
|
onMakePick: handleMakePick,
|
|
onAddToQueue: handleAddToQueue,
|
|
onRemoveFromQueue: handleRemoveFromQueue,
|
|
};
|
|
|
|
const draftGridSectionProps = {
|
|
draftSlots,
|
|
draftGrid,
|
|
currentPick,
|
|
teamTimers,
|
|
autodraftStatus,
|
|
connectedTeams,
|
|
isCommissioner,
|
|
ownerMap,
|
|
onAdjustTimeBankOpen: isCommissioner ? handleAdjustTimeBankOpen : undefined,
|
|
onForceAutopick: handleForceAutopick,
|
|
onForceManualPickOpen: handleForceManualPickOpen,
|
|
onReplacePick: isCommissioner ? handleReplacePickOpen : undefined,
|
|
onRollbackToPick: isCommissioner && !isDraftComplete ? handleRollbackToPick : undefined,
|
|
};
|
|
|
|
const teamsDraftedGridSeason = useMemo(() => ({ numFlexPicks }), [numFlexPicks]);
|
|
|
|
const teamsDraftedGridProps = {
|
|
draftSlots,
|
|
picks,
|
|
sports: seasonSportsData,
|
|
season: teamsDraftedGridSeason,
|
|
ownerMap,
|
|
};
|
|
|
|
const handleAutodraftUpdate = useCallback(
|
|
(isEnabled: boolean, mode: "next_pick" | "while_on", queueOnly: boolean) => {
|
|
setUserAutodraft({ isEnabled, mode, queueOnly });
|
|
},
|
|
[]
|
|
);
|
|
|
|
const queueSectionProps = userTeam
|
|
? {
|
|
queue,
|
|
availableParticipants,
|
|
seasonId: season.id,
|
|
teamId: userTeam.id,
|
|
isMyTurn,
|
|
canPick,
|
|
userAutodraft,
|
|
onRemoveFromQueue: handleRemoveFromQueue,
|
|
onAutodraftUpdate: handleAutodraftUpdate,
|
|
onReorder: handleReorderQueue,
|
|
onMakePick: handleMakePick,
|
|
}
|
|
: null;
|
|
|
|
const mobileTabs = userTeam
|
|
? [MOBILE_TABS_BASE[0], QUEUE_TAB, ...MOBILE_TABS_BASE.slice(1)]
|
|
: MOBILE_TABS_BASE;
|
|
const mobileColCount = mobileTabs.length;
|
|
|
|
return (
|
|
<div className="h-dvh bg-background flex flex-col overflow-hidden">
|
|
{/* Draft Completion Banner */}
|
|
{isDraftComplete && (
|
|
<div className="bg-emerald-500/20 border-b border-emerald-500/30 text-emerald-400 px-4 py-3 text-center font-semibold flex-shrink-0">
|
|
🎉 Draft Complete! The season is now active.
|
|
{season.league.isPublicDraftBoard && (
|
|
<>
|
|
{" "}
|
|
<a
|
|
href={`/leagues/${season.leagueId}/draft-board/${season.id}`}
|
|
className="underline hover:text-emerald-300"
|
|
>
|
|
View Draft Board
|
|
</a>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Standalone Header - No Main Nav */}
|
|
<div className="border-b bg-card flex-shrink-0">
|
|
<div className="w-full px-4 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
{/* Mobile menu button - only show if user has team and sidebar is collapsed */}
|
|
{userTeam && sidebarCollapsed && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setSidebarCollapsed(false)}
|
|
className="lg:hidden"
|
|
aria-label="Open sidebar"
|
|
>
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
width="24"
|
|
height="24"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
>
|
|
<line x1="3" y1="12" x2="21" y2="12"></line>
|
|
<line x1="3" y1="6" x2="21" y2="6"></line>
|
|
<line x1="3" y1="18" x2="21" y2="18"></line>
|
|
</svg>
|
|
</Button>
|
|
)}
|
|
<div>
|
|
<h1 className="text-lg md:text-3xl font-bold">
|
|
{season.league.name} - {season.year} Draft
|
|
</h1>
|
|
<div className="flex gap-4 text-sm text-muted-foreground mt-1">
|
|
<span>Round: {currentRound}</span>
|
|
<span>Pick: {currentPick}</span>
|
|
<span className="capitalize">
|
|
{season.status.replace("_", " ")}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
{/* Commissioner Controls - hidden on mobile (shown in Controls tab) */}
|
|
{isCommissioner && season.status === "pre_draft" && (
|
|
<Button className="hidden md:flex" onClick={handleStartDraft}>Start Draft</Button>
|
|
)}
|
|
|
|
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
|
<div className="hidden md:flex">
|
|
{isPaused ? (
|
|
<Button onClick={handleResumeDraft} variant="default">
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button onClick={handlePauseDraft} variant="outline">
|
|
Pause Draft
|
|
</Button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={`w-3 h-3 rounded-full ${
|
|
isConnected ? "bg-emerald-500" : "bg-coral-accent"
|
|
}`}
|
|
/>
|
|
<span className="hidden sm:inline text-sm font-medium">
|
|
{isConnected ? "Connected" : "Disconnected"}
|
|
</span>
|
|
</div>
|
|
<Button variant="outline" asChild className="hidden md:flex">
|
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Mobile On Clock bar */}
|
|
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
|
<div className={`md:hidden flex-shrink-0 flex items-center justify-between px-4 py-2 border-b ${
|
|
isMyTurn
|
|
? "bg-electric/25 border-electric"
|
|
: "bg-muted/30"
|
|
}`}>
|
|
<div>
|
|
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
|
|
{isMyTurn ? "Your Turn!" : "On the Clock"}
|
|
</div>
|
|
<div className="text-sm font-bold">
|
|
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
|
|
</div>
|
|
{currentDraftSlotOwnerName && (
|
|
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
|
|
)}
|
|
</div>
|
|
{isPaused ? (
|
|
<span className="text-sm font-bold text-amber-accent">Paused</span>
|
|
) : (
|
|
<span className={`text-xl font-mono font-bold tabular-nums ${currentClockColor}`}>
|
|
{formatClockTime(currentClockTime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Content — single layout tree based on isMobile to avoid duplicate component instances */}
|
|
<div className="flex-1 overflow-hidden">
|
|
{isMobile ? (
|
|
<div className="flex h-full overflow-hidden flex-col">
|
|
{mobileTab === "available" && (
|
|
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
|
|
)}
|
|
{mobileTab === "queue" && (
|
|
<div className="h-full overflow-y-auto">
|
|
{queueSectionProps ? (
|
|
<QueueSection {...queueSectionProps} />
|
|
) : (
|
|
<p className="text-muted-foreground text-sm text-center py-8 px-4">
|
|
Join a team to manage your queue
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{mobileTab === "board" && (
|
|
<DraftGridSection {...draftGridSectionProps} />
|
|
)}
|
|
{mobileTab === "roster" && (
|
|
<TeamsDraftedGrid {...teamsDraftedGridProps} />
|
|
)}
|
|
{mobileTab === "controls" && (
|
|
<div className="h-full overflow-y-auto p-4 space-y-6">
|
|
{isCommissioner && season.status === "pre_draft" && (
|
|
<Button className="w-full min-h-[48px]" onClick={handleStartDraft}>
|
|
Start Draft
|
|
</Button>
|
|
)}
|
|
{isCommissioner && season.status === "draft" && !isDraftComplete && (
|
|
isPaused ? (
|
|
<Button className="w-full min-h-[48px]" onClick={handleResumeDraft}>
|
|
Resume Draft
|
|
</Button>
|
|
) : (
|
|
<Button className="w-full min-h-[48px]" variant="outline" onClick={handlePauseDraft}>
|
|
Pause Draft
|
|
</Button>
|
|
)
|
|
)}
|
|
|
|
<section>
|
|
<h2 className="font-semibold text-sm mb-2">Recent Picks</h2>
|
|
<SidebarRecentPicks picks={picks} />
|
|
</section>
|
|
|
|
<NotificationSettings
|
|
enabled={notificationsEnabled}
|
|
onEnabledChange={setNotificationsEnabled}
|
|
mode={notificationsMode}
|
|
onModeChange={setNotificationsMode}
|
|
permissionState={notificationsPermission}
|
|
/>
|
|
|
|
<Button variant="outline" asChild className="w-full min-h-[48px]">
|
|
<Link to={`/leagues/${season.leagueId}`}>Exit Draft Room</Link>
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full overflow-hidden">
|
|
{userTeam && queueSectionProps && (
|
|
<DraftSidebar
|
|
collapsed={sidebarCollapsed}
|
|
onCollapsedChange={setSidebarCollapsed}
|
|
queueSection={<QueueSection {...queueSectionProps} />}
|
|
recentPicksSection={<SidebarRecentPicks picks={picks} />}
|
|
settingsSection={
|
|
<NotificationSettings
|
|
enabled={notificationsEnabled}
|
|
onEnabledChange={setNotificationsEnabled}
|
|
mode={notificationsMode}
|
|
onModeChange={setNotificationsMode}
|
|
permissionState={notificationsPermission}
|
|
/>
|
|
}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex-1 overflow-hidden">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(value) =>
|
|
setActiveTab(value as "participants" | "board" | "teams")
|
|
}
|
|
className="h-full flex flex-col"
|
|
>
|
|
<div className={`mt-4 transition-all duration-300 ${
|
|
isMyTurn && season.status === "draft" && !isDraftComplete
|
|
? "bg-electric/25 border-y-2 border-electric shadow-[0_0_24px_0_rgb(0_200_255_/_0.18)]"
|
|
: ""
|
|
}`}>
|
|
<div className="flex items-center gap-3 px-4 py-2">
|
|
<TabsList>
|
|
<TabsTrigger value="participants">Available Participants</TabsTrigger>
|
|
<TabsTrigger value="board">Draft Board</TabsTrigger>
|
|
<TabsTrigger value="teams">Teams Drafted</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{season.status === "draft" && !isDraftComplete && currentDraftSlot && (
|
|
<div className={`ml-auto flex items-center gap-2.5 rounded-lg px-3 py-1.5 flex-shrink-0 transition-all ${
|
|
isMyTurn
|
|
? "bg-electric/30 border-2 border-electric shadow-md shadow-electric/30"
|
|
: "border border-border/50"
|
|
}`}>
|
|
<div className="leading-tight">
|
|
<div className={`text-xs font-bold uppercase tracking-wider ${isMyTurn ? "text-electric" : "text-muted-foreground"}`}>
|
|
{isMyTurn ? "Your Turn!" : "On the Clock"}
|
|
</div>
|
|
<div className={`text-sm font-bold ${isMyTurn ? "text-foreground" : "text-muted-foreground"}`}>
|
|
{currentDraftSlotOwnerName ?? currentDraftSlot.team.name}
|
|
</div>
|
|
{currentDraftSlotOwnerName && (
|
|
<div className="text-xs text-muted-foreground">{currentDraftSlot.team.name}</div>
|
|
)}
|
|
</div>
|
|
{isPaused ? (
|
|
<span className="text-sm font-bold text-amber-accent">Paused</span>
|
|
) : (
|
|
<span className={`text-2xl font-mono font-bold tabular-nums ${currentClockColor}`}>
|
|
{formatClockTime(currentClockTime)}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="participants" className="flex-1 overflow-hidden m-0">
|
|
<AvailableParticipantsSection {...availableParticipantsSectionProps} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="board" className="flex-1 overflow-hidden m-0">
|
|
<DraftGridSection {...draftGridSectionProps} />
|
|
</TabsContent>
|
|
|
|
<TabsContent value="teams" className="flex-1 overflow-hidden m-0">
|
|
<TeamsDraftedGrid {...teamsDraftedGridProps} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Mobile Bottom Nav */}
|
|
<nav className={`md:hidden flex-shrink-0 border-t bg-card grid`} style={{ gridTemplateColumns: `repeat(${mobileColCount}, 1fr)` }}>
|
|
{mobileTabs.map((tab) => {
|
|
const showTurnIndicator =
|
|
(tab.id === "available" || tab.id === "queue") &&
|
|
isMyTurn &&
|
|
season.status === "draft" &&
|
|
!isDraftComplete;
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
onClick={() => setMobileTab(tab.id as typeof mobileTab)}
|
|
className={`relative flex flex-col items-center justify-center py-2 gap-0.5 min-h-[56px] transition-colors ${
|
|
mobileTab === tab.id ? "text-electric" : "text-muted-foreground"
|
|
}`}
|
|
>
|
|
<div className="relative">
|
|
<tab.Icon className="h-5 w-5" />
|
|
{showTurnIndicator && (
|
|
<span className="absolute -top-1 -right-1 w-2 h-2 rounded-full bg-electric" />
|
|
)}
|
|
</div>
|
|
<span className="text-[10px] font-medium">{tab.label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</nav>
|
|
|
|
<ParticipantSelectionDialog
|
|
open={forcePickDialogOpen && !!selectedPickSlot}
|
|
onOpenChange={(open) => {
|
|
setForcePickDialogOpen(open);
|
|
if (!open) setSelectedPickSlot(null);
|
|
}}
|
|
title="Force Manual Pick"
|
|
description={`Select a participant to draft for Pick #${selectedPickSlot?.pickNumber}`}
|
|
participants={availableParticipants}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
eligibility={forcePickEligibility}
|
|
uniqueSports={uniqueSports}
|
|
onSelect={handleForceManualPick}
|
|
/>
|
|
|
|
<ParticipantSelectionDialog
|
|
open={replacePickDialogOpen && !!replacePickSlot}
|
|
onOpenChange={(open) => {
|
|
setReplacePickDialogOpen(open);
|
|
if (!open) setReplacePickSlot(null);
|
|
}}
|
|
title="Replace Pick"
|
|
description={`Select a participant to replace the current pick at slot #${replacePickSlot?.pickNumber}`}
|
|
participants={availableParticipants}
|
|
draftedParticipantIds={draftedParticipantIds}
|
|
allowParticipantId={replacePickSlot?.oldParticipantId}
|
|
currentParticipantId={replacePickSlot?.oldParticipantId}
|
|
eligibility={replacePickEligibility}
|
|
uniqueSports={uniqueSports}
|
|
onSelect={handleReplacePick}
|
|
/>
|
|
|
|
{/* Rollback Confirmation Dialog */}
|
|
<Dialog
|
|
open={rollbackConfirmOpen}
|
|
onOpenChange={(open) => {
|
|
setRollbackConfirmOpen(open);
|
|
if (!open) setRollbackPickNumber(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Roll Back Draft</DialogTitle>
|
|
<DialogDescription>
|
|
{rollbackPickNumber !== null && (() => {
|
|
const count = picks.filter((p: any) => p.pickNumber >= rollbackPickNumber).length;
|
|
return `This will erase ${count} pick${count === 1 ? "" : "s"} (pick #${rollbackPickNumber} through the most recent) and return the draft to pick #${rollbackPickNumber}. This cannot be undone.`;
|
|
})()}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setRollbackConfirmOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleConfirmRollback} disabled={isRollingBack}>
|
|
{isRollingBack ? "Rolling Back..." : "Roll Back"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Time Bank Adjustment Dialog */}
|
|
<Dialog
|
|
open={timeBankDialogOpen}
|
|
onOpenChange={(open) => {
|
|
setTimeBankDialogOpen(open);
|
|
if (!open) setTimeBankTeamId(null);
|
|
}}
|
|
>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Adjust Time Bank</DialogTitle>
|
|
<DialogDescription>
|
|
{timeBankTeamId
|
|
? `Adjusting time for ${draftSlots.find((s) => s.team.id === timeBankTeamId)?.team.name}`
|
|
: "Adjust a team's time bank"}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="flex flex-col gap-4 py-2">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("add")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "add"
|
|
? "bg-emerald-500/20 border-emerald-500 text-emerald-400"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Add Time
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setTimeBankDirection("remove")}
|
|
className={`flex-1 py-2 rounded-md border text-sm font-medium transition-colors ${
|
|
timeBankDirection === "remove"
|
|
? "bg-destructive/20 border-destructive text-destructive"
|
|
: "border-border text-muted-foreground hover:bg-muted"
|
|
}`}
|
|
>
|
|
Remove Time
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<input
|
|
type="number"
|
|
min="0.001"
|
|
step="any"
|
|
value={timeBankAmount}
|
|
onChange={(e) => setTimeBankAmount(e.target.value)}
|
|
className="flex-1 px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
|
placeholder="Amount"
|
|
/>
|
|
<select
|
|
value={timeBankUnit}
|
|
onChange={(e) =>
|
|
setTimeBankUnit(e.target.value as "seconds" | "minutes" | "hours")
|
|
}
|
|
className="px-3 py-2 border rounded-md bg-background text-foreground text-base md:text-sm"
|
|
>
|
|
<option value="seconds">Seconds</option>
|
|
<option value="minutes">Minutes</option>
|
|
<option value="hours">Hours</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setTimeBankDialogOpen(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmAdjustTimeBank}
|
|
disabled={isAdjustingTimeBank}
|
|
variant={timeBankDirection === "remove" ? "destructive" : "default"}
|
|
>
|
|
{isAdjustingTimeBank
|
|
? "Adjusting..."
|
|
: timeBankDirection === "add"
|
|
? "Add Time"
|
|
: "Remove Time"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* Connection Overlay - blocks interaction until socket connects */}
|
|
<ConnectionOverlay
|
|
isConnected={isConnected}
|
|
isReconnecting={isReconnecting}
|
|
connectionError={connectionError}
|
|
/>
|
|
|
|
{/* Auth Recovery Overlay - blocks interaction when session expires */}
|
|
<AuthRecoveryOverlay isAuthDegraded={authDegraded} />
|
|
</div>
|
|
);
|
|
}
|