import { useState, useEffect } from "react"; import { Form, useBeforeUnload, useBlocker, useNavigation } from "react-router"; import { format } from "date-fns"; import { AlertTriangle, Bell, ListOrdered, Shield, Swords, TrendingUp, Trophy, Users, } from "lucide-react"; import { type ScoringRules, DEFAULT_SCORING_RULES } from "~/lib/scoring-types"; import type { Route } from "./+types/$leagueId.settings"; export { action, loader } from "./$leagueId.settings.server"; import { OMNIFANTASY_SCORING } from "~/components/league/ScoringPresetPicker"; import { Button } from "~/components/ui/button"; import { cn } from "~/lib/utils"; import { SettingsMessage } from "~/components/league/settings/SettingsMessages"; import { LeagueSettingsHeader } from "~/components/league/settings/LeagueSettingsHeader"; import { SettingsDesktopNav, SettingsMobileGridNav, SettingsMobileSectionPill, type SettingsGridSection, } from "~/components/league/settings/SettingsNavigation"; import { SettingsSaveBar } from "~/components/league/settings/SettingsSaveBar"; import { LeaveSettingsDialog, SwitchSettingsSectionDialog } from "~/components/league/settings/UnsavedChangesDialogs"; import { DraftOrderSection } from "~/components/league/settings/DraftOrderSection"; import { LeagueBasicsSection } from "~/components/league/settings/LeagueBasicsSection"; import { DraftSetupSection } from "~/components/league/settings/DraftSetupSection"; import { SportsSection } from "~/components/league/settings/SportsSection"; import { ScoringSection } from "~/components/league/settings/ScoringSection"; import { NotificationsSection } from "~/components/league/settings/NotificationsSection"; import { PeopleSection } from "~/components/league/settings/PeopleSection"; import { HistoryDangerSection } from "~/components/league/settings/HistoryDangerSection"; import { toast } from "sonner"; export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors { return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }]; } const BRACKT_SLUG = "brackt"; type LoaderSeason = Route.ComponentProps["loaderData"]["season"]; type LoaderSportsSeason = Route.ComponentProps["loaderData"]["allSportsSeasons"][number] & { fantasySeasonId?: string | null; }; function getInitialScoringPreset(season: LoaderSeason) { const rules: ScoringRules = { pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, }; if (Object.entries(DEFAULT_SCORING_RULES).every(([k, v]) => rules[k as keyof ScoringRules] === v)) return "brackt" as const; if (Object.entries(OMNIFANTASY_SCORING).every(([k, v]) => rules[k as keyof ScoringRules] === v)) return "omnifantasy" as const; return "custom" as const; } function hasFantasySeasonId(season: object): boolean { return "fantasySeasonId" in season && typeof season.fantasySeasonId === "string" && season.fantasySeasonId.length > 0; } function findTemplateBrackt(seasons: LoaderSportsSeason[]): LoaderSportsSeason | undefined { return seasons.find((ss) => ss.sport.slug === BRACKT_SLUG && !hasFantasySeasonId(ss)); } function getInitialSelectedSports({ season, allSportsSeasons, }: { season: LoaderSeason; allSportsSeasons: LoaderSportsSeason[]; }) { const linkedSeasons = season?.seasonSports?.map((s) => s.sportsSeason) ?? []; const linkedIds = linkedSeasons.map((ss) => ss.id); const templateBrackt = findTemplateBrackt(allSportsSeasons); const privateBrackt = linkedSeasons.find((ss) => ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss)); if (templateBrackt && privateBrackt) { return new Set(linkedIds.map((id) => (id === privateBrackt.id ? templateBrackt.id : id))); } return new Set(linkedIds); } const SETTINGS_SECTIONS = [ { id: "league-basics", label: "League Basics" }, { id: "draft-setup", label: "Draft Settings" }, { id: "draft-order", label: "Draft Order" }, { id: "sports", label: "Sports" }, { id: "scoring", label: "Scoring" }, { id: "notifications", label: "Notifications" }, { id: "people", label: "People" }, { id: "history-danger", label: "History & Danger" }, ] as const; type SettingsSectionId = (typeof SETTINGS_SECTIONS)[number]["id"]; const UPDATE_SECTIONS: SettingsSectionId[] = [ "league-basics", "draft-setup", "sports", "scoring", "notifications", ]; export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) { const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, leagueMembers, ownerMap, commissioners, currentUserId, commishTimezone, } = loaderData; const navigation = useNavigation(); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [mobileView, setMobileView] = useState<"grid" | "section">("grid"); const [activeSection, setActiveSection] = useState("league-basics"); const [pendingSection, setPendingSection] = useState(null); // Two separate dirty flags: one for the multi-section settings form, one for draft order const [hasUnsavedSettingsChanges, setHasUnsavedSettingsChanges] = useState(false); const [hasDraftOrderChanges, setHasDraftOrderChanges] = useState(false); // Settings form state const [leagueName, setLeagueName] = useState(league.name); const [isPublicDraftBoard, setIsPublicDraftBoard] = useState(league.isPublicDraftBoard); const [discordWebhookUrl, setDiscordWebhookUrl] = useState(league.discordWebhookUrl ?? ""); const [teamCountValue, setTeamCountValue] = useState(teamCount); const [timerMode, setTimerMode] = useState<"chess_clock" | "standard">(season?.draftTimerMode ?? "chess_clock"); const [overnightMode, setOvernightMode] = useState<"none" | "league" | "per_user">(season?.overnightPauseMode ?? "none"); const [overnightStart, setOvernightStart] = useState(season?.overnightPauseStart ?? "23:00"); const [overnightEnd, setOvernightEnd] = useState(season?.overnightPauseEnd ?? "07:00"); const [overnightTimezone, setOvernightTimezone] = useState(season?.overnightPauseTimezone ?? commishTimezone ?? ""); const [scoringPreset, setScoringPreset] = useState<"brackt" | "omnifantasy" | "custom">(() => getInitialScoringPreset(season)); const [scoringRules, setScoringRules] = useState({ pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, }); const initTimerMode = season?.draftTimerMode ?? "chess_clock"; const [draftSpeed, setDraftSpeed] = useState(() => { if (initTimerMode === "standard") return season?.draftIncrementTime?.toString() ?? "90"; if (season?.draftInitialTime === 60 && season?.draftIncrementTime === 10) return "fast"; if (season?.draftInitialTime === 120 && season?.draftIncrementTime === 15) return "standard"; if (season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600) return "slow"; if (season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600) return "very-slow"; return "standard"; }); const [selectedSports, setSelectedSports] = useState>( () => getInitialSelectedSports({ season, allSportsSeasons }) ); const [draftRounds, setDraftRounds] = useState(season?.draftRounds || 20); const [draftDate, setDraftDate] = useState( season?.draftDateTime ? new Date(season.draftDateTime) : undefined ); const [draftTime, setDraftTime] = useState( season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : "" ); // Draft order state (separate form, separate dirty flag) const [draftOrderTeams, setDraftOrderTeams] = useState( draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id) ); const [copied, setCopied] = useState(false); // Sync draft order from loader after randomization or other server updates useEffect(() => { const savedOrder = draftSlots.length > 0 ? draftSlots.map((slot) => slot.teamId) : teams.map((team) => team.id); setDraftOrderTeams(savedOrder); setHasDraftOrderChanges(false); }, [draftSlots, teams]); // Clear settings dirty flag after a successful (non-error) submission useEffect(() => { if (!hasUnsavedSettingsChanges || navigation.state !== "idle") return; if (actionData && !("error" in actionData)) { setHasUnsavedSettingsChanges(false); } }, [actionData, hasUnsavedSettingsChanges, navigation.state]); const canEditSports = season?.status === "pre_draft"; const canEditTeamCount = season?.status === "pre_draft"; const canEditDraftOrder = season?.status === "pre_draft"; const canEditDraftRounds = season?.status === "pre_draft"; const minTeamCount = Math.max(6, teamsWithOwners); const sportsCount = selectedSports.size; const minRounds = sportsCount; const recommendedRounds = Math.ceil(sportsCount * 1.25); const flexSpots = Math.max(0, draftRounds - sportsCount); const updateIntent = navigation.formData?.get("intent"); const draftOrderSet = draftSlots.length === teams.length && teams.length > 0; const showUpdateForm = UPDATE_SECTIONS.includes(activeSection); const isSubmittingSettings = navigation.state === "submitting" && updateIntent === "update"; const isSubmittingDraftOrder = navigation.state === "submitting" && updateIntent === "set-draft-order"; const shouldWarnUnsaved = (hasUnsavedSettingsChanges || hasDraftOrderChanges) && !isSubmittingSettings && !isSubmittingDraftOrder; const blocker = useBlocker(shouldWarnUnsaved); const inviteUrl = season?.inviteCode ? `${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}` : ""; const displaySportsSeasons = (() => { const templateBrackt = findTemplateBrackt(allSportsSeasons); if (!templateBrackt) return allSportsSeasons; return allSportsSeasons.filter((ss) => !(ss.sport.slug === BRACKT_SLUG && hasFantasySeasonId(ss))); })(); useBeforeUnload( (event) => { if (!shouldWarnUnsaved) return; event.preventDefault(); event.returnValue = ""; }, { capture: true } ); const markDirty = () => setHasUnsavedSettingsChanges(true); const resetSettingsFormState = () => { setLeagueName(league.name); setIsPublicDraftBoard(league.isPublicDraftBoard); setDiscordWebhookUrl(league.discordWebhookUrl ?? ""); setTeamCountValue(teamCount); setTimerMode(season?.draftTimerMode ?? "chess_clock"); setOvernightMode(season?.overnightPauseMode ?? "none"); setOvernightStart(season?.overnightPauseStart ?? "23:00"); setOvernightEnd(season?.overnightPauseEnd ?? "07:00"); setOvernightTimezone(season?.overnightPauseTimezone ?? commishTimezone ?? ""); setScoringPreset(getInitialScoringPreset(season)); setScoringRules({ pointsFor1st: season?.pointsFor1st ?? DEFAULT_SCORING_RULES.pointsFor1st, pointsFor2nd: season?.pointsFor2nd ?? DEFAULT_SCORING_RULES.pointsFor2nd, pointsFor3rd: season?.pointsFor3rd ?? DEFAULT_SCORING_RULES.pointsFor3rd, pointsFor4th: season?.pointsFor4th ?? DEFAULT_SCORING_RULES.pointsFor4th, pointsFor5th: season?.pointsFor5th ?? DEFAULT_SCORING_RULES.pointsFor5th, pointsFor6th: season?.pointsFor6th ?? DEFAULT_SCORING_RULES.pointsFor6th, pointsFor7th: season?.pointsFor7th ?? DEFAULT_SCORING_RULES.pointsFor7th, pointsFor8th: season?.pointsFor8th ?? DEFAULT_SCORING_RULES.pointsFor8th, }); setSelectedSports(getInitialSelectedSports({ season, allSportsSeasons })); setDraftRounds(season?.draftRounds || 20); setDraftDate(season?.draftDateTime ? new Date(season.draftDateTime) : undefined); setDraftTime(season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""); setHasUnsavedSettingsChanges(false); }; const resetDraftOrder = () => { setDraftOrderTeams( draftSlots.length > 0 ? draftSlots.map((s) => s.teamId) : teams.map((t) => t.id) ); setHasDraftOrderChanges(false); }; const handleSectionChange = (sectionId: SettingsSectionId, { mobile = false } = {}) => { if (sectionId === activeSection && !mobile) return; if (hasUnsavedSettingsChanges) { setPendingSection(sectionId); return; } setActiveSection(sectionId); if (mobile) setMobileView("section"); }; const discardChangesAndSwitchSection = () => { if (!pendingSection) return; resetSettingsFormState(); setActiveSection(pendingSection); setPendingSection(null); setMobileView("section"); }; const handleCopyInviteLink = async () => { if (!season?.inviteCode) return; try { await navigator.clipboard.writeText(inviteUrl); setCopied(true); toast.success("Invite link copied to clipboard!"); setTimeout(() => setCopied(false), 2000); } catch { toast.error("Failed to copy invite link"); } }; const handleSportToggle = (sportId: string) => { setSelectedSports((prev) => { const next = new Set(prev); if (next.has(sportId)) { next.delete(sportId); } else { next.add(sportId); } return next; }); }; const sectionGridData: SettingsGridSection[] = [ { id: "league-basics", label: "League Basics", icon: Shield, subtitle: "Name, teams, join", isComplete: true }, { id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds", isComplete: !!(draftDate && draftTime) }, { id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize", isComplete: draftOrderSet }, { id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected`, isComplete: selectedSports.size > 0 }, { id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties", isComplete: true }, { id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts", isComplete: !!league.discordWebhookUrl }, { id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams`, isComplete: false }, { id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isComplete: false, isDanger: true }, ]; const completedSectionCount = sectionGridData.filter((s) => s.isComplete).length; return (
{mobileView === "grid" && (
handleSectionChange(id as SettingsSectionId, { mobile: true })} />
)} {mobileView === "section" && ( setMobileView("grid")} /> )}
handleSectionChange(id as SettingsSectionId)} />
{showUpdateForm && (
{ setLeagueName(v); markDirty(); }} isPublicDraftBoard={isPublicDraftBoard} onIsPublicDraftBoardChange={(v) => { setIsPublicDraftBoard(v); markDirty(); }} teamCountValue={teamCountValue} onTeamCountValueChange={(v) => { setTeamCountValue(v); markDirty(); }} teamsWithOwners={teamsWithOwners} canEditTeamCount={canEditTeamCount} minTeamCount={minTeamCount} inviteUrl={inviteUrl} copied={copied} onCopyInviteLink={handleCopyInviteLink} /> { setDraftRounds(v); markDirty(); }} draftDate={draftDate} onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }} draftTime={draftTime} onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }} timerMode={timerMode} onTimerModeChange={(v) => { setTimerMode(v); markDirty(); }} draftSpeed={draftSpeed} onDraftSpeedChange={(v) => { setDraftSpeed(v); markDirty(); }} overnightMode={overnightMode} onOvernightModeChange={(v) => { setOvernightMode(v); markDirty(); }} overnightStart={overnightStart} onOvernightStartChange={(v) => { setOvernightStart(v); markDirty(); }} overnightEnd={overnightEnd} onOvernightEndChange={(v) => { setOvernightEnd(v); markDirty(); }} overnightTimezone={overnightTimezone} onOvernightTimezoneChange={(v) => { setOvernightTimezone(v); markDirty(); }} commishTimezone={commishTimezone} /> { handleSportToggle(id); markDirty(); }} onClearAll={() => { setSelectedSports(new Set()); markDirty(); }} /> { setScoringPreset(v); markDirty(); }} scoringRules={scoringRules} onScoringRulesChange={(v) => { setScoringRules(v); markDirty(); }} /> { setDiscordWebhookUrl(v); markDirty(); }} /> )} { if (!open) setPendingSection(null); }} onStay={() => setPendingSection(null)} onDiscard={discardChangesAndSwitchSection} /> {league.discordWebhookUrl && activeSection === "notifications" && (

Test Discord notification

Sends a sample standings update to the saved webhook.

)}
); }