* Add Discord draft pick announcements Posts a message to the league Discord webhook each time a pick is made, announcing the picked participant and pinging the next team on the clock if their owner has opted into Discord notifications. Enabled via a separate toggle in league settings (independent from standings update notifications). https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Address code review feedback on Discord pick announcements - Fix "on the clock" timing: read currentPickNumber fresh from DB post-autodraft-chain (matches sendOnTheClockEmail guarantee) - Remove outer try/catch from notifyPickMadeOnDiscord so callers' .catch() is not dead code - Add missing draft-discord.server.test.ts with 12 tests covering all early-exit and happy paths - Fix silent empty-string fallback for missing pickedSlot: warn and skip instead - Eliminate sequential season→league DB queries by accepting leagueId as a direct param - Show "save webhook URL to configure options" hint when URL is typed but not yet saved - Remove block-scope braces at both call sites (plain const declarations) - Remove redundant "Round N, Pick M" description line (title already carries this info) - Inline pickInRoundFor helper to avoid circular import with draft-utils https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn * Fix lint errors from review fixes - Remove unused logger import (no longer needed after removing try/catch) - Remove unused OWNER_ID constant in test fixture - Use toSorted() instead of sort() in sendDraftOrderNotification https://claude.ai/code/session_01Tvwsv3LfL9JUqxoLct8dTn --------- Co-authored-by: Claude <noreply@anthropic.com>
517 lines
23 KiB
TypeScript
517 lines
23 KiB
TypeScript
import { useState, useEffect, useCallback } from "react";
|
|
import { Form, useBeforeUnload, useBlocker, useLocation, useNavigation, type BlockerFunction } 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 { getInitialDraftSpeed } from "~/lib/draft-timer";
|
|
import { buildDraftOrderTeams } from "~/lib/draft-order";
|
|
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 location = useLocation();
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
|
const [mobileView, setMobileView] = useState<"grid" | "section">("grid");
|
|
const [activeSection, setActiveSection] = useState<SettingsSectionId>("league-basics");
|
|
const [pendingSection, setPendingSection] = useState<SettingsSectionId | null>(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 [discordPicksAnnouncementEnabled, setDiscordPicksAnnouncementEnabled] = useState(league.discordPicksAnnouncementEnabled);
|
|
const [teamCountValue, setTeamCountValue] = useState<number>(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<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,
|
|
});
|
|
const [draftSpeed, setDraftSpeed] = useState(() => getInitialDraftSpeed(season));
|
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
|
() => getInitialSelectedSports({ season, allSportsSeasons })
|
|
);
|
|
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
|
|
const [draftDate, setDraftDate] = useState<Date | undefined>(
|
|
season?.draftDateTime ? new Date(season.draftDateTime) : undefined
|
|
);
|
|
const [draftTime, setDraftTime] = useState<string>(
|
|
season?.draftDateTime ? format(new Date(season.draftDateTime), "HH:mm") : ""
|
|
);
|
|
const [autoStartDraft, setAutoStartDraft] = useState<boolean>(season?.autoStartDraft ?? false);
|
|
|
|
// Draft order state (separate form, separate dirty flag)
|
|
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
|
() => buildDraftOrderTeams(draftSlots, teams)
|
|
);
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
// Sync draft order from loader after randomization or other server updates
|
|
useEffect(() => {
|
|
setDraftOrderTeams(buildDraftOrderTeams(draftSlots, teams));
|
|
setHasDraftOrderChanges(false);
|
|
}, [draftSlots, teams]);
|
|
|
|
// Re-mark dirty if the settings save returned an error. Guard against non-settings errors
|
|
// (e.g. draft-order) which carry a `section` field.
|
|
useEffect(() => {
|
|
if (navigation.state !== "idle") return;
|
|
if (actionData && "error" in actionData && !("section" in actionData)) {
|
|
setHasUnsavedSettingsChanges(true);
|
|
}
|
|
}, [actionData, navigation.state]);
|
|
|
|
useEffect(() => {
|
|
if (navigation.state !== "idle") return;
|
|
if (actionData && "intent" in actionData && actionData.intent === "update" && actionData.success) {
|
|
toast.success(actionData.message ?? "Settings saved!");
|
|
setHasUnsavedSettingsChanges(false);
|
|
}
|
|
}, [actionData, 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(
|
|
useCallback<BlockerFunction>(
|
|
({ nextLocation }) => shouldWarnUnsaved && nextLocation.pathname !== location.pathname,
|
|
[shouldWarnUnsaved, location.pathname]
|
|
)
|
|
);
|
|
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 ?? "");
|
|
setDiscordPicksAnnouncementEnabled(league.discordPicksAnnouncementEnabled);
|
|
setTeamCountValue(teamCount);
|
|
setTimerMode(season?.draftTimerMode ?? "chess_clock");
|
|
setDraftSpeed(getInitialDraftSpeed(season));
|
|
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") : "");
|
|
setAutoStartDraft(season?.autoStartDraft ?? false);
|
|
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" },
|
|
{ id: "draft-setup", label: "Draft Settings", icon: Swords, subtitle: "Format, timing, rounds" },
|
|
{ id: "draft-order", label: "Draft Order", icon: ListOrdered, subtitle: "Pick order, randomize" },
|
|
{ id: "sports", label: "Sports", icon: Trophy, subtitle: `${selectedSports.size} season${selectedSports.size !== 1 ? "s" : ""} selected` },
|
|
{ id: "scoring", label: "Scoring", icon: TrendingUp, subtitle: "Place values, ties" },
|
|
{ id: "notifications", label: "Notifications", icon: Bell, subtitle: "Discord alerts" },
|
|
{ id: "people", label: "People", icon: Users, subtitle: `${teamsWithOwners} of ${teamCountValue} teams` },
|
|
{ id: "history-danger", label: "History & Danger", icon: AlertTriangle, subtitle: "Audit log, dissolve", isDanger: true },
|
|
];
|
|
|
|
return (
|
|
<div className="container mx-auto max-w-5xl px-4 py-6 sm:py-8">
|
|
<div className={cn(mobileView === "section" && "hidden lg:block")}>
|
|
<LeagueSettingsHeader leagueId={league.id} leagueName={league.name} />
|
|
</div>
|
|
|
|
{mobileView === "grid" && (
|
|
<div className="lg:hidden">
|
|
<SettingsMobileGridNav
|
|
sections={sectionGridData}
|
|
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId, { mobile: true })}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{mobileView === "section" && (
|
|
<SettingsMobileSectionPill onShowGrid={() => setMobileView("grid")} />
|
|
)}
|
|
|
|
<div className={cn("grid gap-6 lg:grid-cols-[220px_minmax(0,1fr)]", mobileView === "grid" && "hidden lg:grid")}>
|
|
<SettingsDesktopNav
|
|
sections={sectionGridData}
|
|
activeSection={activeSection}
|
|
onSectionChange={(id) => handleSectionChange(id as SettingsSectionId)}
|
|
/>
|
|
|
|
<main className="space-y-6">
|
|
{showUpdateForm && (
|
|
<Form method="post" noValidate className="space-y-6" onSubmit={() => setHasUnsavedSettingsChanges(false)}>
|
|
<input type="hidden" name="intent" value="update" />
|
|
|
|
<LeagueBasicsSection
|
|
active={activeSection === "league-basics"}
|
|
season={season ?? null}
|
|
leagueName={leagueName}
|
|
onLeagueNameChange={(v) => { 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}
|
|
/>
|
|
|
|
<DraftSetupSection
|
|
active={activeSection === "draft-setup"}
|
|
canEditDraftRounds={canEditDraftRounds}
|
|
minRounds={minRounds}
|
|
recommendedRounds={recommendedRounds}
|
|
flexSpots={flexSpots}
|
|
draftRounds={draftRounds}
|
|
onDraftRoundsChange={(v) => { setDraftRounds(v); markDirty(); }}
|
|
draftDate={draftDate}
|
|
onDraftDateChange={(v) => { setDraftDate(v); markDirty(); }}
|
|
draftTime={draftTime}
|
|
onDraftTimeChange={(v) => { setDraftTime(v); markDirty(); }}
|
|
autoStartDraft={autoStartDraft}
|
|
onAutoStartDraftChange={(v) => { setAutoStartDraft(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}
|
|
/>
|
|
|
|
<SportsSection
|
|
active={activeSection === "sports"}
|
|
canEditSports={canEditSports}
|
|
selectedSports={selectedSports}
|
|
displaySportsSeasons={displaySportsSeasons}
|
|
onSportToggle={(id) => { handleSportToggle(id); markDirty(); }}
|
|
onClearAll={() => { setSelectedSports(new Set()); markDirty(); }}
|
|
/>
|
|
|
|
<ScoringSection
|
|
active={activeSection === "scoring"}
|
|
canEditSports={canEditSports}
|
|
season={season ?? null}
|
|
scoringPreset={scoringPreset}
|
|
onScoringPresetChange={(v) => { setScoringPreset(v); markDirty(); }}
|
|
scoringRules={scoringRules}
|
|
onScoringRulesChange={(v) => { setScoringRules(v); markDirty(); }}
|
|
/>
|
|
|
|
<NotificationsSection
|
|
active={activeSection === "notifications"}
|
|
savedDiscordWebhookUrl={league.discordWebhookUrl}
|
|
discordWebhookUrl={discordWebhookUrl}
|
|
onDiscordWebhookUrlChange={(v) => { setDiscordWebhookUrl(v); markDirty(); }}
|
|
discordPicksAnnouncementEnabled={discordPicksAnnouncementEnabled}
|
|
onDiscordPicksAnnouncementEnabledChange={(v) => { setDiscordPicksAnnouncementEnabled(v); markDirty(); }}
|
|
/>
|
|
|
|
<SettingsMessage actionData={actionData} />
|
|
|
|
<SettingsSaveBar
|
|
hasUnsavedChanges={hasUnsavedSettingsChanges}
|
|
isSubmitting={isSubmittingSettings}
|
|
onReset={resetSettingsFormState}
|
|
/>
|
|
</Form>
|
|
)}
|
|
|
|
<LeaveSettingsDialog blocker={blocker} />
|
|
|
|
<SwitchSettingsSectionDialog
|
|
open={pendingSection !== null}
|
|
onOpenChange={(open) => { if (!open) setPendingSection(null); }}
|
|
onStay={() => setPendingSection(null)}
|
|
onDiscard={discardChangesAndSwitchSection}
|
|
/>
|
|
|
|
<DraftOrderSection
|
|
active={activeSection === "draft-order"}
|
|
draftOrderSet={draftOrderSet}
|
|
canEditDraftOrder={canEditDraftOrder}
|
|
hasUnsavedChanges={hasDraftOrderChanges}
|
|
draftOrderTeams={draftOrderTeams}
|
|
setDraftOrderTeams={setDraftOrderTeams}
|
|
onDirtyChange={setHasDraftOrderChanges}
|
|
onResetChanges={resetDraftOrder}
|
|
teams={teams}
|
|
ownerMap={ownerMap}
|
|
navigationState={navigation.state}
|
|
updateIntent={updateIntent}
|
|
actionData={actionData}
|
|
/>
|
|
|
|
<PeopleSection
|
|
active={activeSection === "people"}
|
|
teams={teams}
|
|
ownerMap={ownerMap}
|
|
commissioners={commissioners}
|
|
currentUserId={currentUserId}
|
|
teamCountValue={teamCountValue}
|
|
teamsWithOwners={teamsWithOwners}
|
|
leagueMembers={leagueMembers}
|
|
isAdmin={isAdmin}
|
|
navigationState={navigation.state}
|
|
/>
|
|
|
|
{league.discordWebhookUrl && activeSection === "notifications" && (
|
|
<Form method="post" className="rounded-lg border bg-card p-4">
|
|
<input type="hidden" name="intent" value="test-discord-webhook" />
|
|
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<p className="font-medium">Test Discord notification</p>
|
|
<p className="text-sm text-muted-foreground">Sends a sample standings update to the saved webhook.</p>
|
|
</div>
|
|
<Button
|
|
type="submit"
|
|
variant="outline"
|
|
disabled={navigation.state === "submitting" && updateIntent === "test-discord-webhook"}
|
|
>
|
|
Send Test
|
|
</Button>
|
|
</div>
|
|
</Form>
|
|
)}
|
|
|
|
<HistoryDangerSection
|
|
active={activeSection === "history-danger"}
|
|
league={{ id: league.id, name: league.name }}
|
|
season={season ?? null}
|
|
isAdmin={isAdmin}
|
|
isDeleteDialogOpen={isDeleteDialogOpen}
|
|
onDeleteDialogOpenChange={setIsDeleteDialogOpen}
|
|
navigationState={navigation.state}
|
|
/>
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|