2025-10-15 08:58:35 -07:00
|
|
|
|
import { useState, useEffect } from "react";
|
|
|
|
|
|
import { Form, Link, redirect, useNavigation } from "react-router";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import { getAuth } from "@clerk/react-router/server";
|
2025-10-15 22:02:21 -07:00
|
|
|
|
import { format } from "date-fns";
|
|
|
|
|
|
import { CalendarIcon } from "lucide-react";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import { findLeagueById, updateLeague, deleteLeague } from "~/models/league";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
import { sendStandingsUpdateNotification } from "~/services/discord";
|
2026-02-20 08:45:09 -08:00
|
|
|
|
import {
|
|
|
|
|
|
isCommissioner,
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
|
hasCommissionerRecord,
|
2026-02-20 08:45:09 -08:00
|
|
|
|
findCommissionersByLeagueId,
|
|
|
|
|
|
createCommissioner,
|
|
|
|
|
|
countCommissionersByLeagueId,
|
|
|
|
|
|
removeCommissionerByLeagueAndUser,
|
|
|
|
|
|
} from "~/models/commissioner";
|
2025-10-15 21:50:02 -07:00
|
|
|
|
import { findCurrentSeasonWithSports, updateSeason } from "~/models/season";
|
2026-03-18 00:53:40 -07:00
|
|
|
|
import { findTeamsBySeasonId, deleteTeam, removeTeamOwner, claimTeam } from "~/models/team";
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
import { findAllUsers, findUserByClerkId, findUsersByClerkIds, getUserDisplayName, isUserAdminByClerkId } from "~/models/user";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
import { unlinkSportFromSeason, linkMultipleSportsToSeason } from "~/models/season-sport";
|
2026-03-18 22:40:19 -07:00
|
|
|
|
import { findDraftableSportsSeasons } from "~/models/sports-season";
|
2025-10-15 08:58:35 -07:00
|
|
|
|
import { findDraftSlotsBySeasonId, setDraftOrder, randomizeDraftOrder } from "~/models/draft-slot";
|
2026-02-24 10:01:27 -08:00
|
|
|
|
import { database } from "~/database/context";
|
|
|
|
|
|
import * as schema from "~/database/schema";
|
2025-10-21 22:34:26 -07:00
|
|
|
|
import { deleteAllDraftPicks } from "~/models/draft-pick";
|
|
|
|
|
|
import { clearAllQueuesForSeason } from "~/models/draft-queue";
|
2025-10-24 21:41:30 -07:00
|
|
|
|
import { deleteSeasonTimers } from "~/models/draft-timer";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import type { Route } from "./+types/$leagueId.settings";
|
|
|
|
|
|
import { Button } from "~/components/ui/button";
|
|
|
|
|
|
import { Input } from "~/components/ui/input";
|
|
|
|
|
|
import { Label } from "~/components/ui/label";
|
2025-10-15 22:02:21 -07:00
|
|
|
|
import { Calendar } from "~/components/ui/calendar";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Popover,
|
|
|
|
|
|
PopoverContent,
|
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
|
} from "~/components/ui/popover";
|
|
|
|
|
|
import { cn } from "~/lib/utils";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import {
|
|
|
|
|
|
Card,
|
|
|
|
|
|
CardContent,
|
|
|
|
|
|
CardDescription,
|
|
|
|
|
|
CardHeader,
|
|
|
|
|
|
CardTitle,
|
|
|
|
|
|
} from "~/components/ui/card";
|
|
|
|
|
|
import {
|
|
|
|
|
|
Select,
|
|
|
|
|
|
SelectContent,
|
|
|
|
|
|
SelectItem,
|
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
|
SelectValue,
|
|
|
|
|
|
} from "~/components/ui/select";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
import { Badge } from "~/components/ui/badge";
|
|
|
|
|
|
import { Checkbox } from "~/components/ui/checkbox";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
import {
|
|
|
|
|
|
AlertDialog,
|
|
|
|
|
|
AlertDialogAction,
|
|
|
|
|
|
AlertDialogCancel,
|
|
|
|
|
|
AlertDialogContent,
|
|
|
|
|
|
AlertDialogDescription,
|
|
|
|
|
|
AlertDialogFooter,
|
|
|
|
|
|
AlertDialogHeader,
|
|
|
|
|
|
AlertDialogTitle,
|
|
|
|
|
|
AlertDialogTrigger,
|
|
|
|
|
|
} from "~/components/ui/alert-dialog";
|
2025-10-29 00:04:27 -07:00
|
|
|
|
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor";
|
2026-03-05 10:07:15 -08:00
|
|
|
|
import { toast } from "sonner";
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2026-03-10 12:10:52 -07:00
|
|
|
|
export function meta({ data }: Route.MetaArgs): Route.MetaDescriptors {
|
|
|
|
|
|
return [{ title: `Settings — ${data?.league?.name ?? "League"} - Brackt` }];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
export async function loader(args: Route.LoaderArgs) {
|
|
|
|
|
|
const { userId } = await getAuth(args);
|
|
|
|
|
|
const { params } = args;
|
|
|
|
|
|
const { leagueId } = params;
|
|
|
|
|
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
|
|
throw new Response("Unauthorized", { status: 401 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch league
|
|
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
|
|
|
|
|
|
|
|
|
|
if (!league) {
|
|
|
|
|
|
throw new Response("League not found", { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if user is authorized (creator or commissioner)
|
|
|
|
|
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
|
|
|
|
|
const isCreator = league.createdBy === userId;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isCreator && !userIsCommissioner) {
|
|
|
|
|
|
throw new Response("Forbidden - You must be a commissioner to access settings", {
|
|
|
|
|
|
status: 403,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 22:40:19 -07:00
|
|
|
|
// Get current season with sports and draftable seasons in parallel
|
|
|
|
|
|
const [season, draftableSportsSeasons] = await Promise.all([
|
|
|
|
|
|
findCurrentSeasonWithSports(leagueId),
|
|
|
|
|
|
findDraftableSportsSeasons(),
|
|
|
|
|
|
]);
|
2025-10-11 00:29:04 -07:00
|
|
|
|
const teams = season ? await findTeamsBySeasonId(season.id) : [];
|
2026-03-18 22:40:19 -07:00
|
|
|
|
// Merge draftable seasons with any already-linked non-draftable seasons so that
|
|
|
|
|
|
// previously-added seasons remain visible and checked in the UI even after being disabled.
|
|
|
|
|
|
const draftableIds = new Set(draftableSportsSeasons.map((s) => s.id));
|
|
|
|
|
|
const linkedButNotDraftable = (season?.seasonSports ?? [])
|
|
|
|
|
|
.map((s) => s.sportsSeason)
|
|
|
|
|
|
.filter((ss) => !ss.isDraftable && !draftableIds.has(ss.id));
|
|
|
|
|
|
const allSportsSeasons = [...draftableSportsSeasons, ...linkedButNotDraftable];
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const draftSlots = season ? await findDraftSlotsBySeasonId(season.id) : [];
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
|
|
|
|
|
// Count teams with owners
|
|
|
|
|
|
const teamsWithOwners = teams.filter(team => team.ownerId !== null).length;
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
// Check if user is admin
|
|
|
|
|
|
const isAdmin = await isUserAdminByClerkId(userId);
|
|
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
// Get all users (only for admins - used in team assignment dropdown)
|
2025-10-21 22:15:15 -07:00
|
|
|
|
const allUsers = isAdmin ? await findAllUsers() : [];
|
|
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
// Get commissioners for this league with user info
|
|
|
|
|
|
const commissioners = await findCommissionersByLeagueId(leagueId);
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
|
|
|
|
|
|
// Batch-fetch all users needed for commissioner and owner lookups in one query
|
|
|
|
|
|
const uniqueOwnerIds = [...new Set(teams.map((t) => t.ownerId).filter((id): id is string => id !== null))];
|
|
|
|
|
|
const commissionerUserIds = commissioners.map((c) => c.userId);
|
|
|
|
|
|
const allUserIds = [...new Set([...commissionerUserIds, ...uniqueOwnerIds])];
|
|
|
|
|
|
const userRows = await findUsersByClerkIds(allUserIds);
|
|
|
|
|
|
const userByClerkId = new Map(userRows.map((u) => [u.clerkId, u]));
|
|
|
|
|
|
|
|
|
|
|
|
const commissionerUserData = commissioners.map((c) => {
|
|
|
|
|
|
const user = userByClerkId.get(c.userId);
|
|
|
|
|
|
return {
|
|
|
|
|
|
...c,
|
|
|
|
|
|
userName: user ? (getUserDisplayName(user) ?? "Unknown User") : "Unknown User",
|
|
|
|
|
|
};
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const validOwners = uniqueOwnerIds
|
|
|
|
|
|
.map((ownerId) => {
|
|
|
|
|
|
const user = userByClerkId.get(ownerId);
|
|
|
|
|
|
return user ? { clerkId: ownerId, name: getUserDisplayName(user), id: user.id } : null;
|
2025-10-21 22:15:15 -07:00
|
|
|
|
})
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
.filter((o): o is NonNullable<typeof o> => o !== null);
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const ownerMap = new Map(validOwners.map((o) => [o.clerkId, o.name]));
|
|
|
|
|
|
|
|
|
|
|
|
// League members (team owners) - available to all commissioners for adding co-commissioners
|
|
|
|
|
|
const leagueMembers = validOwners.map((o) => ({
|
|
|
|
|
|
id: o.id,
|
|
|
|
|
|
clerkId: o.clerkId,
|
|
|
|
|
|
name: o.name,
|
|
|
|
|
|
}));
|
2025-10-21 22:15:15 -07:00
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
return {
|
|
|
|
|
|
league,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
season,
|
|
|
|
|
|
teams,
|
2025-10-11 00:29:04 -07:00
|
|
|
|
teamCount: teams.length,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
teamsWithOwners,
|
|
|
|
|
|
allSportsSeasons: allSportsSeasons as Array<typeof allSportsSeasons[0] & { sport: { id: string; name: string; type: string; slug: string } }>,
|
2025-10-15 08:58:35 -07:00
|
|
|
|
draftSlots,
|
2025-10-21 22:15:15 -07:00
|
|
|
|
isAdmin,
|
|
|
|
|
|
allUsers,
|
2026-02-20 08:45:09 -08:00
|
|
|
|
leagueMembers,
|
2025-10-21 22:15:15 -07:00
|
|
|
|
ownerMap: Object.fromEntries(ownerMap),
|
2026-02-20 08:45:09 -08:00
|
|
|
|
commissioners: commissionerUserData,
|
|
|
|
|
|
currentUserId: userId,
|
2025-10-11 00:29:04 -07:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function action(args: Route.ActionArgs) {
|
|
|
|
|
|
const { userId } = await getAuth(args);
|
|
|
|
|
|
const { params, request } = args;
|
|
|
|
|
|
const { leagueId } = params;
|
|
|
|
|
|
|
|
|
|
|
|
if (!userId) {
|
|
|
|
|
|
throw new Response("Unauthorized", { status: 401 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Verify authorization
|
|
|
|
|
|
const league = await findLeagueById(leagueId);
|
|
|
|
|
|
if (!league) {
|
|
|
|
|
|
throw new Response("League not found", { status: 404 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const userIsCommissioner = await isCommissioner(leagueId, userId);
|
|
|
|
|
|
const isCreator = league.createdBy === userId;
|
|
|
|
|
|
|
|
|
|
|
|
if (!isCreator && !userIsCommissioner) {
|
|
|
|
|
|
throw new Response("Forbidden", { status: 403 });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const formData = await request.formData();
|
|
|
|
|
|
const intent = formData.get("intent");
|
|
|
|
|
|
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
// For league-level settings (name, public draft board), save immediately
|
|
|
|
|
|
// before checking season, so they work even if no season exists
|
|
|
|
|
|
if (intent === "update") {
|
|
|
|
|
|
const name = formData.get("name");
|
|
|
|
|
|
const isPublicDraftBoard = formData.get("isPublicDraftBoard") === "on";
|
2026-03-17 11:16:36 -07:00
|
|
|
|
const discordWebhookUrl = formData.get("discordWebhookUrl");
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
|
|
|
|
|
|
if (typeof name !== "string" || !name.trim()) {
|
|
|
|
|
|
return { error: "League name is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (name.trim().length < 3 || name.trim().length > 50) {
|
|
|
|
|
|
return { error: "League name must be between 3 and 50 characters" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
const webhookUrl =
|
|
|
|
|
|
typeof discordWebhookUrl === "string" ? discordWebhookUrl.trim() : "";
|
|
|
|
|
|
if (webhookUrl && !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
|
|
|
|
|
return { error: "Discord webhook URL must start with https://discord.com/api/webhooks/" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
try {
|
|
|
|
|
|
await updateLeague(leagueId, {
|
|
|
|
|
|
name: name.trim(),
|
|
|
|
|
|
isPublicDraftBoard,
|
2026-03-17 11:16:36 -07:00
|
|
|
|
discordWebhookUrl: webhookUrl || null,
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
});
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error updating league:", error);
|
|
|
|
|
|
return { error: "Failed to update league. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
if (intent === "test-discord-webhook") {
|
|
|
|
|
|
const webhookUrl = formData.get("webhookUrl");
|
|
|
|
|
|
if (typeof webhookUrl !== "string" || !webhookUrl.startsWith("https://discord.com/api/webhooks/")) {
|
|
|
|
|
|
return { error: "Invalid Discord webhook URL" };
|
|
|
|
|
|
}
|
|
|
|
|
|
try {
|
|
|
|
|
|
const testSeason = await findCurrentSeasonWithSports(leagueId);
|
|
|
|
|
|
const seasonName = testSeason ? `${league.name} ${testSeason.year}` : league.name;
|
|
|
|
|
|
await sendStandingsUpdateNotification({
|
|
|
|
|
|
webhookUrl,
|
|
|
|
|
|
seasonName,
|
|
|
|
|
|
standings: [
|
|
|
|
|
|
{ teamId: "1", teamName: "Team Alpha", totalPoints: 150, rank: 1 },
|
|
|
|
|
|
{ teamId: "2", teamName: "Team Beta", totalPoints: 125, rank: 2 },
|
|
|
|
|
|
{ teamId: "3", teamName: "Team Gamma", totalPoints: 100, rank: 3 },
|
|
|
|
|
|
],
|
|
|
|
|
|
previousStandings: new Map([
|
|
|
|
|
|
["1", 125],
|
|
|
|
|
|
["2", 125],
|
|
|
|
|
|
["3", 100],
|
|
|
|
|
|
]),
|
2026-03-19 15:52:57 -07:00
|
|
|
|
previousRanks: new Map([
|
|
|
|
|
|
["1", 2],
|
|
|
|
|
|
["2", 1],
|
|
|
|
|
|
["3", 3],
|
|
|
|
|
|
]),
|
2026-03-17 11:16:36 -07:00
|
|
|
|
eventName: "Test Notification",
|
2026-03-19 15:52:57 -07:00
|
|
|
|
scoredMatches: [
|
|
|
|
|
|
{ winnerName: "Team Alpha", loserName: "Team Beta", winnerUsername: "manager1", loserUsername: "manager2" },
|
|
|
|
|
|
],
|
2026-03-17 11:16:36 -07:00
|
|
|
|
});
|
|
|
|
|
|
return { testSuccess: true };
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error("Discord test webhook failed:", err);
|
|
|
|
|
|
return { error: "Failed to send test notification. Check your webhook URL." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
// Get current season to check status
|
|
|
|
|
|
const season = await findCurrentSeasonWithSports(leagueId);
|
|
|
|
|
|
if (!season) {
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
if (intent === "update") {
|
|
|
|
|
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
|
|
|
|
|
}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
return { error: "No active season found" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "update-sports") {
|
|
|
|
|
|
if (season.status !== "pre_draft") {
|
|
|
|
|
|
return { error: "Cannot modify sports after draft has started" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const selectedSports = formData.getAll("sportsSeasons");
|
|
|
|
|
|
const currentSportIds = new Set(
|
|
|
|
|
|
season.seasonSports?.map((s: any) => s.sportsSeason.id) || []
|
|
|
|
|
|
);
|
|
|
|
|
|
const newSportIds = new Set(selectedSports as string[]);
|
|
|
|
|
|
|
|
|
|
|
|
// Remove sports that are no longer selected
|
|
|
|
|
|
for (const sportId of currentSportIds) {
|
|
|
|
|
|
if (!newSportIds.has(sportId)) {
|
|
|
|
|
|
await unlinkSportFromSeason(season.id, sportId);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add newly selected sports
|
|
|
|
|
|
const sportsToAdd = [];
|
|
|
|
|
|
for (const sportId of newSportIds) {
|
|
|
|
|
|
if (!currentSportIds.has(sportId)) {
|
|
|
|
|
|
sportsToAdd.push({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
sportsSeasonId: sportId as string,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (sportsToAdd.length > 0) {
|
|
|
|
|
|
await linkMultipleSportsToSeason(sportsToAdd);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return { success: true };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
if (intent === "set-draft-order") {
|
|
|
|
|
|
if (season.status !== "pre_draft") {
|
|
|
|
|
|
return { error: "Cannot modify draft order after draft has started" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
|
|
|
|
const teamIds = formData.getAll("teamOrder") as string[];
|
|
|
|
|
|
|
|
|
|
|
|
// Validate that all teams are included
|
|
|
|
|
|
if (teamIds.length !== teams.length) {
|
|
|
|
|
|
return { error: "All teams must be included in the draft order" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Validate that all team IDs are valid
|
|
|
|
|
|
const validTeamIds = new Set(teams.map(t => t.id));
|
|
|
|
|
|
for (const teamId of teamIds) {
|
|
|
|
|
|
if (!validTeamIds.has(teamId)) {
|
|
|
|
|
|
return { error: "Invalid team ID in draft order" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await setDraftOrder(season.id, teamIds);
|
|
|
|
|
|
return { success: true, message: "Draft order updated successfully" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "randomize-draft-order") {
|
|
|
|
|
|
if (season.status !== "pre_draft") {
|
|
|
|
|
|
return { error: "Cannot modify draft order after draft has started" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
|
|
|
|
const teamIds = teams.map(t => t.id);
|
|
|
|
|
|
|
|
|
|
|
|
await randomizeDraftOrder(season.id, teamIds);
|
|
|
|
|
|
return { success: true, message: "Draft order randomized successfully" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
if (intent === "remove-team-owner") {
|
|
|
|
|
|
const teamId = formData.get("teamId") as string;
|
|
|
|
|
|
|
|
|
|
|
|
if (!teamId) {
|
|
|
|
|
|
return { error: "Team ID is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await removeTeamOwner(teamId);
|
|
|
|
|
|
return { success: true, message: "Owner removed successfully" };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error removing team owner:", error);
|
|
|
|
|
|
return { error: "Failed to remove owner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "assign-team-owner") {
|
|
|
|
|
|
const teamId = formData.get("teamId") as string;
|
|
|
|
|
|
const userClerkId = formData.get("userClerkId") as string;
|
|
|
|
|
|
|
|
|
|
|
|
if (!teamId || !userClerkId) {
|
|
|
|
|
|
return { error: "Team ID and User ID are required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Check if user is admin (only admins can assign owners)
|
|
|
|
|
|
const isAdmin = await isUserAdminByClerkId(userId);
|
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
return { error: "Only admins can assign team owners" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:53:40 -07:00
|
|
|
|
// Look up user before assigning to fail fast and get their name
|
|
|
|
|
|
const assignedUser = await findUserByClerkId(userClerkId);
|
|
|
|
|
|
if (!assignedUser) {
|
|
|
|
|
|
return { error: "User not found. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:27:14 -07:00
|
|
|
|
// Check if user is already assigned to a team in this season
|
|
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
|
|
|
|
const userAlreadyHasTeam = teams.some(team => team.ownerId === userClerkId);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
|
2025-10-21 22:27:14 -07:00
|
|
|
|
if (userAlreadyHasTeam) {
|
|
|
|
|
|
return { error: "This user is already assigned to a team in this league" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-18 00:53:40 -07:00
|
|
|
|
// Check if the target team already has an owner
|
|
|
|
|
|
const targetTeam = teams.find(team => team.id === teamId);
|
|
|
|
|
|
if (targetTeam?.ownerId) {
|
|
|
|
|
|
return { error: "This team already has an owner. Remove the current owner first." };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
try {
|
Optimize user data fetching with batch queries and centralize display name logic (#176)
* Fall back to displayName when username is null for Discord webhook
Users who sign up via OAuth (Google, GitHub, etc.) without setting a
Clerk username have a null `username` field but always have a `displayName`
(computed from firstName+lastName or email). Previously, `usernameByClerkId`
was filtered to only include users with a non-null username, causing those
owners to appear without any identifier in Discord standings messages
(e.g. "Liverpool def. Galatasaray" instead of "Liverpool def. Galatasaray (Madmike)").
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Extract getUserDisplayName helper and use consistently throughout
Add a single getUserDisplayName(user) function to app/models/user.ts that
encapsulates the username → displayName fallback logic. Replace 9 scattered
inline expressions across the codebase (owner-map, scoring-calculator,
league routes, settings, invite flow, draft API, Clerk webhook) with calls
to the shared helper.
No behaviour change — all existing logic preserved, just centralised.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in league loader and settings loader
Add findUsersByClerkIds() batch function to the user model and replace two
separate Promise.all+findUserByClerkId loops (one for owners, one for
commissioners) with a single inArray query in both $leagueId.server.ts and
$leagueId.settings.tsx. The merged query covers both owner and commissioner
IDs in one round-trip.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
* Fix N+1 user queries in buildOwnerMap
Replace the Promise.all+findUserByClerkId loop with a single
findUsersByClerkIds batch query, consistent with the league loader
and settings loader fixes.
https://claude.ai/code/session_01VAkeDDVZMYS1DweQnUrRnH
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-03-18 16:46:07 -07:00
|
|
|
|
const teamName = `Team ${getUserDisplayName(assignedUser) ?? "Member"}`;
|
2026-03-18 00:53:40 -07:00
|
|
|
|
await claimTeam(teamId, userClerkId, teamName);
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
return { success: true, message: "Owner assigned successfully" };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error assigning team owner:", error);
|
|
|
|
|
|
return { error: "Failed to assign owner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
if (intent === "reset-draft") {
|
|
|
|
|
|
// Check if user is admin (only admins can reset draft)
|
|
|
|
|
|
const isAdmin = await isUserAdminByClerkId(userId);
|
|
|
|
|
|
if (!isAdmin) {
|
|
|
|
|
|
return { error: "Only admins can reset the draft" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
// Delete all draft picks
|
|
|
|
|
|
await deleteAllDraftPicks(season.id);
|
2025-10-24 21:41:30 -07:00
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
// Clear all draft queues
|
|
|
|
|
|
await clearAllQueuesForSeason(season.id);
|
2025-10-24 21:41:30 -07:00
|
|
|
|
|
|
|
|
|
|
// Delete all draft timers
|
|
|
|
|
|
await deleteSeasonTimers(season.id);
|
|
|
|
|
|
|
|
|
|
|
|
// Set season status back to pre_draft and reset draft state (keeps draft order intact)
|
|
|
|
|
|
await updateSeason(season.id, {
|
|
|
|
|
|
status: "pre_draft",
|
|
|
|
|
|
currentPickNumber: null,
|
|
|
|
|
|
draftStartedAt: null,
|
|
|
|
|
|
draftPaused: false,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-10-21 22:34:26 -07:00
|
|
|
|
return { success: true, message: "Draft has been reset successfully. Draft order preserved." };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error resetting draft:", error);
|
|
|
|
|
|
return { error: "Failed to reset draft. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
if (intent === "delete") {
|
|
|
|
|
|
// Delete the league
|
|
|
|
|
|
await deleteLeague(leagueId);
|
|
|
|
|
|
|
|
|
|
|
|
// Redirect to home with success message
|
|
|
|
|
|
return redirect("/?deleted=true");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "update") {
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const teamCount = formData.get("teamCount");
|
2025-10-15 22:02:21 -07:00
|
|
|
|
const draftDateTime = formData.get("draftDateTime");
|
|
|
|
|
|
const draftRounds = formData.get("draftRounds");
|
2025-10-20 15:03:11 -07:00
|
|
|
|
const draftSpeed = formData.get("draftSpeed");
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
|
2025-10-20 15:03:11 -07:00
|
|
|
|
// Map draft speed to time values
|
|
|
|
|
|
let draftInitialTime: number;
|
|
|
|
|
|
let draftIncrementTime: number;
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
|
2025-10-20 15:03:11 -07:00
|
|
|
|
switch (draftSpeed) {
|
|
|
|
|
|
case "fast":
|
|
|
|
|
|
draftInitialTime = 60;
|
|
|
|
|
|
draftIncrementTime = 10;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "standard":
|
|
|
|
|
|
draftInitialTime = 120;
|
|
|
|
|
|
draftIncrementTime = 15;
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "slow":
|
|
|
|
|
|
draftInitialTime = 28800; // 8 hours
|
|
|
|
|
|
draftIncrementTime = 3600; // 1 hour
|
|
|
|
|
|
break;
|
|
|
|
|
|
case "very-slow":
|
|
|
|
|
|
draftInitialTime = 43200; // 12 hours
|
|
|
|
|
|
draftIncrementTime = 3600; // 1 hour
|
|
|
|
|
|
break;
|
|
|
|
|
|
default:
|
|
|
|
|
|
draftInitialTime = 120;
|
|
|
|
|
|
draftIncrementTime = 15;
|
|
|
|
|
|
}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
// League-level fields (name, isPublicDraftBoard) are already saved above.
|
|
|
|
|
|
// Now handle season-specific updates.
|
2025-10-11 00:29:04 -07:00
|
|
|
|
try {
|
2025-10-15 22:02:21 -07:00
|
|
|
|
// Update season settings
|
|
|
|
|
|
const seasonUpdates: any = {};
|
2025-10-29 00:04:27 -07:00
|
|
|
|
|
2025-10-15 22:02:21 -07:00
|
|
|
|
// Handle draft rounds
|
|
|
|
|
|
if (typeof draftRounds === "string") {
|
|
|
|
|
|
const draftRoundsNum = parseInt(draftRounds, 10);
|
|
|
|
|
|
if (!isNaN(draftRoundsNum)) {
|
|
|
|
|
|
const sportsCount = season.seasonSports?.length || 0;
|
|
|
|
|
|
if (draftRoundsNum < sportsCount) {
|
|
|
|
|
|
return { error: `Draft rounds must be at least ${sportsCount} (number of sports selected)` };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (draftRoundsNum < 1 || draftRoundsNum > 50) {
|
|
|
|
|
|
return { error: "Draft rounds must be between 1 and 50" };
|
|
|
|
|
|
}
|
|
|
|
|
|
seasonUpdates.draftRounds = draftRoundsNum;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Handle draft date/time
|
|
|
|
|
|
if (typeof draftDateTime === "string") {
|
|
|
|
|
|
seasonUpdates.draftDateTime = draftDateTime ? new Date(draftDateTime) : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-23 23:23:24 -08:00
|
|
|
|
// Set draft times from speed preset (only if draftSpeed was submitted;
|
|
|
|
|
|
// the select is disabled during an active draft so it won't be present)
|
|
|
|
|
|
if (draftSpeed !== null) {
|
|
|
|
|
|
seasonUpdates.draftInitialTime = draftInitialTime;
|
|
|
|
|
|
seasonUpdates.draftIncrementTime = draftIncrementTime;
|
|
|
|
|
|
}
|
2025-10-16 00:37:51 -07:00
|
|
|
|
|
2025-10-29 00:04:27 -07:00
|
|
|
|
// Handle scoring rules (only if in pre_draft status)
|
|
|
|
|
|
if (season.status === "pre_draft") {
|
|
|
|
|
|
const scoringFields = [
|
|
|
|
|
|
"pointsFor1st", "pointsFor2nd", "pointsFor3rd", "pointsFor4th",
|
|
|
|
|
|
"pointsFor5th", "pointsFor6th", "pointsFor7th", "pointsFor8th"
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (const field of scoringFields) {
|
|
|
|
|
|
const value = formData.get(field);
|
|
|
|
|
|
if (typeof value === "string") {
|
|
|
|
|
|
const points = parseInt(value, 10);
|
|
|
|
|
|
if (!isNaN(points)) {
|
|
|
|
|
|
if (points < 0 || points > 1000) {
|
|
|
|
|
|
return { error: `${field} must be between 0 and 1000 points` };
|
|
|
|
|
|
}
|
|
|
|
|
|
seasonUpdates[field] = points;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-15 22:02:21 -07:00
|
|
|
|
// Update season if there are changes
|
|
|
|
|
|
if (Object.keys(seasonUpdates).length > 0) {
|
|
|
|
|
|
await updateSeason(season.id, seasonUpdates);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
// Handle team count changes if provided
|
|
|
|
|
|
if (typeof teamCount === "string") {
|
|
|
|
|
|
const newTeamCount = parseInt(teamCount, 10);
|
|
|
|
|
|
|
|
|
|
|
|
if (!isNaN(newTeamCount)) {
|
|
|
|
|
|
const teams = await findTeamsBySeasonId(season.id);
|
|
|
|
|
|
const currentTeamCount = teams.length;
|
|
|
|
|
|
const teamsWithOwners = teams.filter(t => t.ownerId !== null).length;
|
|
|
|
|
|
|
|
|
|
|
|
// Validate team count
|
|
|
|
|
|
if (newTeamCount < 6 || newTeamCount > 16) {
|
|
|
|
|
|
return { error: "Number of teams must be between 6 and 16" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (newTeamCount < teamsWithOwners) {
|
|
|
|
|
|
return { error: `Cannot reduce team count below ${teamsWithOwners} (number of teams with owners)` };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Add or remove teams as needed
|
|
|
|
|
|
if (newTeamCount > currentTeamCount) {
|
|
|
|
|
|
const teamsToAdd = Array.from(
|
|
|
|
|
|
{ length: newTeamCount - currentTeamCount },
|
|
|
|
|
|
(_, i) => ({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
name: `Team ${currentTeamCount + i + 1}`,
|
2026-02-24 10:01:27 -08:00
|
|
|
|
ownerId: null as null,
|
2025-10-13 19:59:34 -07:00
|
|
|
|
})
|
|
|
|
|
|
);
|
2026-02-24 10:01:27 -08:00
|
|
|
|
|
|
|
|
|
|
// Fetch existing slots before the transaction to determine append position
|
|
|
|
|
|
const existingSlots = await findDraftSlotsBySeasonId(season.id);
|
|
|
|
|
|
const maxOrder = existingSlots.reduce((max, s) => Math.max(max, s.draftOrder), 0);
|
|
|
|
|
|
|
|
|
|
|
|
// Create teams and their draft slots atomically
|
|
|
|
|
|
const db = database();
|
|
|
|
|
|
await db.transaction(async (tx) => {
|
|
|
|
|
|
const newTeams = await tx
|
|
|
|
|
|
.insert(schema.teams)
|
|
|
|
|
|
.values(teamsToAdd)
|
|
|
|
|
|
.returning();
|
|
|
|
|
|
await tx.insert(schema.draftSlots).values(
|
|
|
|
|
|
newTeams.map((team, i) => ({
|
|
|
|
|
|
seasonId: season.id,
|
|
|
|
|
|
teamId: team.id,
|
|
|
|
|
|
draftOrder: maxOrder + i + 1,
|
|
|
|
|
|
}))
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
2025-10-13 19:59:34 -07:00
|
|
|
|
} else if (newTeamCount < currentTeamCount) {
|
|
|
|
|
|
// Remove teams without owners (from the end)
|
|
|
|
|
|
const teamsToRemove = teams
|
|
|
|
|
|
.filter(t => t.ownerId === null)
|
|
|
|
|
|
.slice(-(currentTeamCount - newTeamCount));
|
|
|
|
|
|
|
|
|
|
|
|
for (const team of teamsToRemove) {
|
|
|
|
|
|
await deleteTeam(team.id);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 01:03:31 -07:00
|
|
|
|
return redirect(`/leagues/${leagueId}?updated=true`);
|
2025-10-11 00:29:04 -07:00
|
|
|
|
} catch (error) {
|
Fix public draft board access not working when setting is enabled (#23)
* Fix public draft board access not working when setting is enabled
Two bugs were causing the isPublicDraftBoard setting to fail:
1. Draft board loader called getAuth() before checking isPublicDraftBoard.
Restructured to skip auth entirely for public boards - only call getAuth
when the board is private and we need to verify member/commissioner access.
2. Settings action saved the league (name + isPublicDraftBoard) AFTER fetching
the current season. If the season fetch had any issue, updateLeague was never
reached. Moved the league-level save to happen unconditionally before the
season fetch, so isPublicDraftBoard is always persisted on form submit.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix bugs in draft board access and settings update action
- Wrap updateLeague call in try-catch so DB errors return a user-friendly
message instead of an unhandled 500
- Fix season-update catch block to say "season settings" not "league" since
the league was already saved successfully at that point
- Validate leagueId URL param against season.leagueId in draft board loader
to prevent accessing a season via the wrong league's URL
- Change 403 message for authenticated non-members from "not public" to
"you don't have access" to accurately reflect their logged-in state
- Add settings-update.test.ts covering name validation, league save,
error handling, draft speed mapping, and season-specific validation
- Add draft-board-access.test.ts covering public/private access rules,
leagueId mismatch detection, commissioner/owner/unauthenticated cases,
and the new per-role 403 message distinction
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
* Fix TypeScript errors in settings-update tests
TS was narrowing const draftSpeed = 'fast' to the literal type 'fast',
making comparisons with 'slow'/'very-slow' etc. flagged as impossible.
Widen all draftSpeed locals and the season status locals to string so the
if-chains compile cleanly under strict mode.
https://claude.ai/code/session_01Jp2tE3YXhx2jdb6CgCnvZy
---------
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-22 16:31:24 -08:00
|
|
|
|
console.error("Error updating season settings:", error);
|
|
|
|
|
|
return { error: "Failed to update season settings. Please try again." };
|
2025-10-11 00:29:04 -07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
if (intent === "add-commissioner") {
|
|
|
|
|
|
const userClerkId = formData.get("userClerkId") as string;
|
|
|
|
|
|
|
|
|
|
|
|
if (!userClerkId) {
|
|
|
|
|
|
return { error: "User is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
Grant sitewide admins commissioner-level access in leagues (#162)
* Grant sitewide admins commissioner-level access in leagues
- `isCommissioner()` now returns true for site admins, covering all
commissioner-gated loaders (league home, settings, sport season detail)
and draft API routes (start, pause, resume, rollback, replace-pick,
force-autopick, force-manual-pick, adjust-time-bank, make-pick)
- Added `hasCommissionerRecord()` (DB-only, no admin bypass) for the
"already a commissioner" duplicate-entry check in the settings action,
preventing a false positive when adding a site admin as commissioner
- `isCommissioner()` now runs the admin check and DB query in parallel
via Promise.all to avoid a serial roundtrip on every check
- Added "admin" to the `picked_by_type` enum (migration 0049) so picks
forced by a site admin are recorded accurately in the audit log rather
than as "commissioner"
- 8 unit tests covering both isCommissioner and hasCommissionerRecord
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix draft.force-manual-pick tests broken by isUserAdminByClerkId
The route now calls isUserAdminByClerkId which hits database().query.users,
but the test's mock DB had no query.users entry. Add a vi.mock for
~/models/user and default isUserAdminByClerkId to false in beforeEach.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 00:41:56 -07:00
|
|
|
|
// Check if user already has a commissioner record (don't use isCommissioner — it
|
|
|
|
|
|
// returns true for site admins, causing a false "already a commissioner" error)
|
|
|
|
|
|
const alreadyCommissioner = await hasCommissionerRecord(leagueId, userClerkId);
|
2026-02-20 08:45:09 -08:00
|
|
|
|
if (alreadyCommissioner) {
|
|
|
|
|
|
return { error: "This user is already a commissioner" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await createCommissioner({ leagueId, userId: userClerkId });
|
|
|
|
|
|
return { success: true, message: "Commissioner added successfully" };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error adding commissioner:", error);
|
|
|
|
|
|
return { error: "Failed to add commissioner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (intent === "remove-commissioner") {
|
|
|
|
|
|
const commissionerUserId = formData.get("commissionerUserId") as string;
|
|
|
|
|
|
|
|
|
|
|
|
if (!commissionerUserId) {
|
|
|
|
|
|
return { error: "User is required" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent self-removal
|
|
|
|
|
|
if (commissionerUserId === userId) {
|
|
|
|
|
|
return { error: "You cannot remove yourself as a commissioner" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent removing the last commissioner
|
|
|
|
|
|
const count = await countCommissionersByLeagueId(leagueId);
|
|
|
|
|
|
if (count <= 1) {
|
|
|
|
|
|
return { error: "Cannot remove the last commissioner" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await removeCommissionerByLeagueAndUser(leagueId, commissionerUserId);
|
|
|
|
|
|
return { success: true, message: "Commissioner removed successfully" };
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error("Error removing commissioner:", error);
|
|
|
|
|
|
return { error: "Failed to remove commissioner. Please try again." };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
return { error: "Invalid action" };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default function LeagueSettings({ loaderData, actionData }: Route.ComponentProps) {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const { league, season, teams, teamCount, teamsWithOwners, allSportsSeasons, draftSlots, isAdmin, allUsers, leagueMembers, ownerMap, commissioners, currentUserId } = loaderData;
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const navigation = useNavigation();
|
2025-10-11 00:29:04 -07:00
|
|
|
|
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const [selectedSports, setSelectedSports] = useState<Set<string>>(
|
|
|
|
|
|
new Set(season?.seasonSports?.map((s: any) => s.sportsSeason.id) || [])
|
|
|
|
|
|
);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const [draftOrderTeams, setDraftOrderTeams] = useState<string[]>(
|
2026-02-20 08:45:09 -08:00
|
|
|
|
draftSlots.length > 0
|
|
|
|
|
|
? draftSlots.map((slot) => slot.teamId)
|
|
|
|
|
|
: teams.map((team) => team.id)
|
2025-10-15 08:58:35 -07:00
|
|
|
|
);
|
2025-10-15 21:50:02 -07:00
|
|
|
|
const [draftRounds, setDraftRounds] = useState<number>(season?.draftRounds || 20);
|
2025-10-15 22:02:21 -07:00
|
|
|
|
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")
|
|
|
|
|
|
: ""
|
|
|
|
|
|
);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
|
|
|
|
|
|
// Update draft order when loader data changes (after randomization)
|
|
|
|
|
|
useEffect(() => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const newOrder = draftSlots.length > 0
|
|
|
|
|
|
? draftSlots.map((slot) => slot.teamId)
|
|
|
|
|
|
: teams.map((team) => team.id);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
setDraftOrderTeams(newOrder);
|
|
|
|
|
|
}, [draftSlots, teams]);
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
|
|
|
|
|
const canEditSports = season && season.status === "pre_draft";
|
|
|
|
|
|
const canEditTeamCount = season && season.status === "pre_draft";
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const canEditDraftOrder = season && season.status === "pre_draft";
|
2025-10-15 21:50:02 -07:00
|
|
|
|
const canEditDraftRounds = season && season.status === "pre_draft";
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const minTeamCount = Math.max(6, teamsWithOwners);
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
// Calculate flex spots and minimum rounds
|
|
|
|
|
|
const sportsCount = selectedSports.size;
|
|
|
|
|
|
const minRounds = sportsCount;
|
|
|
|
|
|
const recommendedRounds = Math.ceil(sportsCount * 1.25);
|
|
|
|
|
|
const flexSpots = Math.max(0, draftRounds - sportsCount);
|
|
|
|
|
|
|
2025-10-13 19:59:34 -07:00
|
|
|
|
const handleSportToggle = (sportId: string) => {
|
|
|
|
|
|
const newSelected = new Set(selectedSports);
|
|
|
|
|
|
if (newSelected.has(sportId)) {
|
|
|
|
|
|
newSelected.delete(sportId);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
newSelected.add(sportId);
|
|
|
|
|
|
}
|
|
|
|
|
|
setSelectedSports(newSelected);
|
|
|
|
|
|
};
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
|
|
|
|
|
const handleDelete = () => {
|
|
|
|
|
|
setIsDeleteDialogOpen(false);
|
|
|
|
|
|
// The form submission will handle the actual deletion
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-15 08:58:35 -07:00
|
|
|
|
const moveDraftSlot = (fromIndex: number, toIndex: number) => {
|
|
|
|
|
|
const newOrder = [...draftOrderTeams];
|
|
|
|
|
|
const [movedTeam] = newOrder.splice(fromIndex, 1);
|
|
|
|
|
|
newOrder.splice(toIndex, 0, movedTeam);
|
|
|
|
|
|
setDraftOrderTeams(newOrder);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const getTeamById = (teamId: string) => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
return teams.find((t) => t.id === teamId);
|
2025-10-15 08:58:35 -07:00
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-05 10:07:15 -08:00
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
|
|
|
|
const handleCopyInviteLink = async () => {
|
|
|
|
|
|
if (!season?.inviteCode) return;
|
|
|
|
|
|
const inviteUrl = `${window.location.origin}/i/${season.inviteCode}`;
|
|
|
|
|
|
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");
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="container max-w-2xl mx-auto py-8 px-4">
|
|
|
|
|
|
<div className="mb-8">
|
|
|
|
|
|
<div className="flex items-center justify-between mb-2">
|
|
|
|
|
|
<h1 className="text-4xl font-bold">League Settings</h1>
|
|
|
|
|
|
<Button variant="outline" asChild>
|
|
|
|
|
|
<Link to={`/leagues/${league.id}`}>Back to League</Link>
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-muted-foreground">{league.name}</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-05 10:07:15 -08:00
|
|
|
|
{season?.inviteCode && (
|
|
|
|
|
|
<Card className="mb-6">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Invite Link</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Share this link to invite people to join your league
|
|
|
|
|
|
{teamCount - teamsWithOwners > 0 && ` (${teamCount - teamsWithOwners} spot${teamCount - teamsWithOwners !== 1 ? "s" : ""} available)`}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-3">
|
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
readOnly
|
|
|
|
|
|
value={`${typeof window !== "undefined" ? window.location.origin : ""}/i/${season.inviteCode}`}
|
|
|
|
|
|
className="flex-1 px-3 py-2 text-sm border rounded-md bg-muted"
|
|
|
|
|
|
onClick={(e) => e.currentTarget.select()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
onClick={handleCopyInviteLink}
|
|
|
|
|
|
variant={copied ? "default" : "outline"}
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
>
|
|
|
|
|
|
{copied ? "Copied!" : "Copy"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
|
|
|
|
|
Anyone with this link can join your league
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<Form method="post" className="space-y-6">
|
|
|
|
|
|
<input type="hidden" name="intent" value="update" />
|
2026-03-05 10:07:15 -08:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* General Settings */}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>General Settings</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Update your league's basic information
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="name">League Name</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="name"
|
|
|
|
|
|
name="name"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
defaultValue={league.name}
|
|
|
|
|
|
placeholder="Enter league name"
|
|
|
|
|
|
required
|
|
|
|
|
|
minLength={3}
|
|
|
|
|
|
maxLength={50}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-20 15:03:11 -07:00
|
|
|
|
<div className="flex items-center space-x-2">
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="checkbox"
|
|
|
|
|
|
id="isPublicDraftBoard"
|
|
|
|
|
|
name="isPublicDraftBoard"
|
|
|
|
|
|
defaultChecked={league.isPublicDraftBoard}
|
2026-02-20 19:26:11 -08:00
|
|
|
|
className="h-4 w-4 rounded border-border"
|
2025-10-20 15:03:11 -07:00
|
|
|
|
/>
|
|
|
|
|
|
<Label htmlFor="isPublicDraftBoard" className="font-normal cursor-pointer">
|
|
|
|
|
|
Make draft board publicly accessible (no login required)
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="teamCount">Number of Teams</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
name="teamCount"
|
|
|
|
|
|
defaultValue={teamCount.toString()}
|
|
|
|
|
|
disabled={!canEditTeamCount}
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id="teamCount">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{Array.from({ length: 11 }, (_, i) => i + 6).map((num) => (
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={num}
|
|
|
|
|
|
value={num.toString()}
|
|
|
|
|
|
disabled={num < minTeamCount}
|
|
|
|
|
|
>
|
|
|
|
|
|
{num} Teams
|
|
|
|
|
|
{num < minTeamCount && ` (${teamsWithOwners} teams have owners)`}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
{canEditTeamCount
|
|
|
|
|
|
? `Can be changed before draft starts (minimum ${minTeamCount} based on registered teams)`
|
|
|
|
|
|
: "Team count cannot be changed after draft has started"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
|
2026-03-17 11:16:36 -07:00
|
|
|
|
{/* Notifications */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Notifications</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Get Discord alerts when standings change after scoring events
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="discordWebhookUrl">Discord Webhook URL</Label>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
id="discordWebhookUrl"
|
|
|
|
|
|
name="discordWebhookUrl"
|
|
|
|
|
|
type="url"
|
|
|
|
|
|
defaultValue={league.discordWebhookUrl ?? ""}
|
|
|
|
|
|
placeholder="https://discord.com/api/webhooks/..."
|
|
|
|
|
|
/>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Create a webhook in your Discord server under Server Settings → Integrations → Webhooks.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{league.discordWebhookUrl && (
|
|
|
|
|
|
<Form method="post" className="pt-2">
|
|
|
|
|
|
<input type="hidden" name="intent" value="test-discord-webhook" />
|
|
|
|
|
|
<input type="hidden" name="webhookUrl" value={league.discordWebhookUrl} />
|
|
|
|
|
|
<Button type="submit" variant="outline" size="sm">
|
|
|
|
|
|
Send Test Notification
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{actionData && "testSuccess" in actionData && actionData.testSuccess && (
|
|
|
|
|
|
<p className="text-sm text-emerald-600">Test notification sent to Discord!</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* Draft Rounds */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Draft Rounds</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{canEditDraftRounds
|
|
|
|
|
|
? "Set the number of draft rounds for your league"
|
|
|
|
|
|
: "Draft rounds cannot be modified after the draft has started"}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="draftRounds">Number of Draft Rounds</Label>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
name="draftRounds"
|
|
|
|
|
|
value={draftRounds.toString()}
|
|
|
|
|
|
onValueChange={(value) => setDraftRounds(parseInt(value))}
|
|
|
|
|
|
disabled={!canEditDraftRounds}
|
|
|
|
|
|
required
|
|
|
|
|
|
>
|
|
|
|
|
|
<SelectTrigger id="draftRounds">
|
|
|
|
|
|
<SelectValue />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
|
|
|
|
|
{Array.from({ length: 50 }, (_, i) => i + 1)
|
|
|
|
|
|
.filter(num => num >= minRounds)
|
|
|
|
|
|
.map((num) => (
|
2025-10-13 19:59:34 -07:00
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={num}
|
|
|
|
|
|
value={num.toString()}
|
|
|
|
|
|
>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{num} Round{num !== 1 ? 's' : ''}
|
|
|
|
|
|
{num === recommendedRounds && ' (recommended)'}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</SelectItem>
|
|
|
|
|
|
))}
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<div className="space-y-1">
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-10-15 21:50:02 -07:00
|
|
|
|
Minimum: {minRounds} (number of sports selected)
|
|
|
|
|
|
{recommendedRounds > minRounds && ` • Recommended: ${recommendedRounds}`}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</p>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{sportsCount > 0 && draftRounds < sportsCount && (
|
|
|
|
|
|
<p className="text-sm font-medium text-destructive">
|
|
|
|
|
|
⚠️ You need at least {sportsCount} rounds for the {sportsCount} sports selected
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{sportsCount > 0 && draftRounds >= sportsCount && (
|
|
|
|
|
|
<p className="text-sm font-medium text-primary">
|
|
|
|
|
|
Flex Spots: {flexSpots}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</div>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</div>
|
2025-10-15 22:02:21 -07:00
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label htmlFor="draftDate">Draft Date & Time</Label>
|
|
|
|
|
|
<div className="grid gap-2 sm:grid-cols-2">
|
|
|
|
|
|
<Popover>
|
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
variant={"outline"}
|
|
|
|
|
|
className={cn(
|
|
|
|
|
|
"justify-start text-left font-normal",
|
|
|
|
|
|
!draftDate && "text-muted-foreground"
|
|
|
|
|
|
)}
|
|
|
|
|
|
disabled={!canEditDraftRounds}
|
|
|
|
|
|
>
|
|
|
|
|
|
<CalendarIcon className="mr-2 h-4 w-4" />
|
|
|
|
|
|
{draftDate ? format(draftDate, "PPP") : <span>Pick a date</span>}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
|
<PopoverContent className="w-auto p-0">
|
|
|
|
|
|
<Calendar
|
|
|
|
|
|
mode="single"
|
|
|
|
|
|
selected={draftDate}
|
|
|
|
|
|
onSelect={setDraftDate}
|
|
|
|
|
|
disabled={(date) => date < new Date(new Date().setHours(0, 0, 0, 0))}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
type="time"
|
|
|
|
|
|
value={draftTime}
|
|
|
|
|
|
onChange={(e) => setDraftTime(e.target.value)}
|
|
|
|
|
|
placeholder="Select time"
|
|
|
|
|
|
disabled={!canEditDraftRounds}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{draftDate && draftTime && (
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="hidden"
|
|
|
|
|
|
name="draftDateTime"
|
|
|
|
|
|
value={new Date(`${format(draftDate, "yyyy-MM-dd")}T${draftTime}`).toISOString()}
|
|
|
|
|
|
/>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{!draftDate && !draftTime && (
|
|
|
|
|
|
<input type="hidden" name="draftDateTime" value="" />
|
|
|
|
|
|
)}
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
{canEditDraftRounds
|
|
|
|
|
|
? "You must set a draft date and time before starting the draft"
|
|
|
|
|
|
: "Draft date cannot be changed after draft has started"}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
2025-10-16 00:37:51 -07:00
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
2025-10-20 15:03:11 -07:00
|
|
|
|
<Label htmlFor="draftSpeed">Draft Speed</Label>
|
|
|
|
|
|
<select
|
|
|
|
|
|
id="draftSpeed"
|
|
|
|
|
|
name="draftSpeed"
|
|
|
|
|
|
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
|
|
|
|
|
defaultValue={
|
|
|
|
|
|
season?.draftInitialTime === 60 && season?.draftIncrementTime === 10
|
|
|
|
|
|
? "fast"
|
|
|
|
|
|
: season?.draftInitialTime === 120 && season?.draftIncrementTime === 15
|
|
|
|
|
|
? "standard"
|
|
|
|
|
|
: season?.draftInitialTime === 28800 && season?.draftIncrementTime === 3600
|
|
|
|
|
|
? "slow"
|
|
|
|
|
|
: season?.draftInitialTime === 43200 && season?.draftIncrementTime === 3600
|
|
|
|
|
|
? "very-slow"
|
|
|
|
|
|
: "standard"
|
|
|
|
|
|
}
|
2025-10-16 00:37:51 -07:00
|
|
|
|
disabled={!canEditDraftRounds}
|
|
|
|
|
|
required
|
2025-10-20 15:03:11 -07:00
|
|
|
|
>
|
|
|
|
|
|
<option value="fast">Fast (1 min + 10 sec)</option>
|
|
|
|
|
|
<option value="standard">Standard (2 min + 15 sec)</option>
|
|
|
|
|
|
<option value="slow">Slow (8 hours + 1 hour)</option>
|
|
|
|
|
|
<option value="very-slow">Very Slow (12 hours + 1 hour)</option>
|
|
|
|
|
|
</select>
|
2025-10-16 00:37:51 -07:00
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-10-20 15:03:11 -07:00
|
|
|
|
Initial time + increment per pick
|
2025-10-16 00:37:51 -07:00
|
|
|
|
</p>
|
2026-02-23 23:23:24 -08:00
|
|
|
|
|
2025-10-16 00:37:51 -07:00
|
|
|
|
</div>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-29 00:04:27 -07:00
|
|
|
|
{/* Scoring Rules */}
|
|
|
|
|
|
<ScoringRulesEditor
|
|
|
|
|
|
scoringRules={season ? {
|
|
|
|
|
|
pointsFor1st: season.pointsFor1st,
|
|
|
|
|
|
pointsFor2nd: season.pointsFor2nd,
|
|
|
|
|
|
pointsFor3rd: season.pointsFor3rd,
|
|
|
|
|
|
pointsFor4th: season.pointsFor4th,
|
|
|
|
|
|
pointsFor5th: season.pointsFor5th,
|
|
|
|
|
|
pointsFor6th: season.pointsFor6th,
|
|
|
|
|
|
pointsFor7th: season.pointsFor7th,
|
|
|
|
|
|
pointsFor8th: season.pointsFor8th,
|
|
|
|
|
|
} : undefined}
|
|
|
|
|
|
disabled={!season || season.status !== "pre_draft"}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* Sports Seasons */}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Sports Seasons</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{canEditSports
|
|
|
|
|
|
? "Select the sports seasons that teams can draft from"
|
|
|
|
|
|
: "Sports cannot be modified after the draft has started"}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<CardContent className="space-y-4">
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
<strong>Recommendation:</strong> Select 16-20 sports seasons for optimal league balance and variety.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<Label className="text-sm font-medium">
|
|
|
|
|
|
Sports Seasons ({selectedSports.size} selected)
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
<div className="max-h-96 overflow-y-auto border rounded-md p-3 space-y-2">
|
|
|
|
|
|
{allSportsSeasons.length > 0 ? (
|
|
|
|
|
|
allSportsSeasons.map((season) => (
|
|
|
|
|
|
<div key={season.id} className="flex items-center space-x-2">
|
|
|
|
|
|
<Checkbox
|
|
|
|
|
|
id={season.id}
|
|
|
|
|
|
name="sportsSeasons"
|
|
|
|
|
|
value={season.id}
|
|
|
|
|
|
checked={selectedSports.has(season.id)}
|
|
|
|
|
|
onCheckedChange={() => handleSportToggle(season.id)}
|
|
|
|
|
|
disabled={!canEditSports}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<Label
|
|
|
|
|
|
htmlFor={season.id}
|
|
|
|
|
|
className={`font-normal flex-1 ${canEditSports ? 'cursor-pointer' : 'cursor-not-allowed opacity-60'}`}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="font-medium">{season.sport.name}</span> - {season.name} ({season.year})
|
|
|
|
|
|
<Badge variant="outline" className="ml-2 text-xs">
|
|
|
|
|
|
{season.scoringType.replace("_", " ")}
|
|
|
|
|
|
</Badge>
|
|
|
|
|
|
</Label>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
No sports seasons available
|
|
|
|
|
|
</p>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-10-15 21:50:02 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{/* Save Button */}
|
|
|
|
|
|
{actionData?.error && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
{actionData?.success && (
|
2026-02-20 19:26:11 -08:00
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
2025-10-15 21:50:02 -07:00
|
|
|
|
Settings updated successfully!
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<Button type="submit" size="lg" className="w-full">
|
|
|
|
|
|
Save All Settings
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
2025-10-13 19:59:34 -07:00
|
|
|
|
|
2025-10-15 21:50:02 -07:00
|
|
|
|
<div className="space-y-6">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
{/* Draft Order Management */}
|
2025-10-21 21:36:42 -07:00
|
|
|
|
<Card id="draft-order">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Draft Order</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
{canEditDraftOrder
|
|
|
|
|
|
? "Set the draft order for your league. Drag teams to reorder or randomize."
|
|
|
|
|
|
: "Draft order cannot be changed after the draft has started"}
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
|
{draftSlots.length === 0 && canEditDraftOrder && (
|
|
|
|
|
|
<div className="bg-muted/50 border border-muted-foreground/20 rounded-md p-3">
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
<strong>Note:</strong> Draft order has not been set yet. Use the form below to set the order manually or click "Randomize" to generate a random order.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Form method="post" className="space-y-4">
|
|
|
|
|
|
<input type="hidden" name="intent" value="set-draft-order" />
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<div className="border rounded-md divide-y">
|
|
|
|
|
|
{draftOrderTeams.map((teamId, index) => {
|
|
|
|
|
|
const team = getTeamById(teamId);
|
|
|
|
|
|
if (!team) return null;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={teamId} className="flex items-center gap-3 p-3">
|
|
|
|
|
|
<input type="hidden" name="teamOrder" value={teamId} />
|
|
|
|
|
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold text-sm">
|
|
|
|
|
|
{index + 1}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<p className="font-medium">{team.name}</p>
|
2026-03-18 00:53:40 -07:00
|
|
|
|
{team.ownerId && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">{ownerMap[team.ownerId] ?? "Unknown"}</p>
|
|
|
|
|
|
)}
|
2025-10-15 08:58:35 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
{canEditDraftOrder && (
|
|
|
|
|
|
<div className="flex gap-1">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => moveDraftSlot(index, Math.max(0, index - 1))}
|
|
|
|
|
|
disabled={index === 0}
|
|
|
|
|
|
>
|
|
|
|
|
|
↑
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
onClick={() => moveDraftSlot(index, Math.min(draftOrderTeams.length - 1, index + 1))}
|
|
|
|
|
|
disabled={index === draftOrderTeams.length - 1}
|
|
|
|
|
|
>
|
|
|
|
|
|
↓
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{canEditDraftOrder && (
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
<Button type="submit" className="flex-1">
|
|
|
|
|
|
Save Draft Order
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{actionData?.success && actionData?.message && (
|
2026-02-20 19:26:11 -08:00
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
2025-10-15 08:58:35 -07:00
|
|
|
|
{actionData.message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{actionData?.error && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
|
|
|
|
|
|
{canEditDraftOrder && (
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="randomize-draft-order" />
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
className="w-full"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
{navigation.state === "submitting" && navigation.formData?.get("intent") === "randomize-draft-order"
|
|
|
|
|
|
? "Randomizing..."
|
|
|
|
|
|
: "🎲 Randomize Draft Order"}
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-21 22:15:15 -07:00
|
|
|
|
{/* Team Management */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Team Management</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Manage team ownership for this league
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent>
|
|
|
|
|
|
<div className="space-y-3">
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{teams.map((team) => {
|
2025-10-21 22:15:15 -07:00
|
|
|
|
const ownerName = team.ownerId ? ownerMap[team.ownerId] : null;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={team.id}
|
|
|
|
|
|
className="flex items-center justify-between p-3 border rounded-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<p className="font-medium">{team.name}</p>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
{team.ownerId ? (
|
|
|
|
|
|
<span>Owner: {ownerName || "Unknown"}</span>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<span>No owner</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="flex gap-2">
|
|
|
|
|
|
{team.ownerId && (
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="remove-team-owner" />
|
|
|
|
|
|
<input type="hidden" name="teamId" value={team.id} />
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Remove Owner
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{isAdmin && (
|
|
|
|
|
|
<Form method="post" className="flex gap-2">
|
|
|
|
|
|
<input type="hidden" name="intent" value="assign-team-owner" />
|
|
|
|
|
|
<input type="hidden" name="teamId" value={team.id} />
|
|
|
|
|
|
<Select name="userClerkId" required>
|
|
|
|
|
|
<SelectTrigger className="w-[180px] h-9">
|
|
|
|
|
|
<SelectValue placeholder="Select user" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{allUsers.map((user) => {
|
|
|
|
|
|
const userOwnsTeam = teams.some((t) => t.ownerId === user.clerkId);
|
2025-10-21 22:27:14 -07:00
|
|
|
|
return (
|
2026-02-20 08:45:09 -08:00
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={user.id}
|
2025-10-21 22:27:14 -07:00
|
|
|
|
value={user.clerkId}
|
|
|
|
|
|
disabled={userOwnsTeam}
|
|
|
|
|
|
>
|
|
|
|
|
|
{user.username || user.email}
|
|
|
|
|
|
{userOwnsTeam && " (already in league)"}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-10-21 22:15:15 -07:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Assign
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{/* Commissioner Management */}
|
|
|
|
|
|
<Card>
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle>Commissioner Management</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Commissioners can manage league settings and oversee the draft. They do not need to own a team.
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
|
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{actionData?.error && !actionData?.success && (
|
|
|
|
|
|
<div className="bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm">
|
|
|
|
|
|
{actionData.error}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{actionData?.success && actionData?.message && (
|
2026-02-20 19:26:11 -08:00
|
|
|
|
<div className="bg-emerald-500/15 text-emerald-400 px-4 py-3 rounded-md text-sm">
|
2026-02-20 08:45:09 -08:00
|
|
|
|
{actionData.message}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
|
{commissioners.map((commissioner) => (
|
|
|
|
|
|
<div
|
|
|
|
|
|
key={commissioner.id}
|
|
|
|
|
|
className="flex items-center justify-between p-3 border rounded-md"
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="flex-1">
|
|
|
|
|
|
<p className="font-medium">
|
|
|
|
|
|
{commissioner.userName}
|
|
|
|
|
|
{commissioner.userId === currentUserId && (
|
|
|
|
|
|
<span className="text-xs text-muted-foreground ml-2">(you)</span>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</p>
|
|
|
|
|
|
{!teams.some((t) => t.ownerId === commissioner.userId) && (
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">No team in current season</p>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{commissioners.length > 1 && commissioner.userId !== currentUserId && (
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="remove-commissioner" />
|
|
|
|
|
|
<input type="hidden" name="commissionerUserId" value={commissioner.userId} />
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Remove
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="border-t pt-4">
|
|
|
|
|
|
<p className="text-sm font-medium mb-3">Add Commissioner</p>
|
|
|
|
|
|
<Form method="post" className="flex gap-2">
|
|
|
|
|
|
<input type="hidden" name="intent" value="add-commissioner" />
|
|
|
|
|
|
<Select name="userClerkId" required>
|
|
|
|
|
|
<SelectTrigger className="flex-1">
|
|
|
|
|
|
<SelectValue placeholder="Select a user" />
|
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
|
<SelectContent>
|
2026-03-18 00:53:40 -07:00
|
|
|
|
{leagueMembers.map((member) => {
|
2026-02-20 08:45:09 -08:00
|
|
|
|
const alreadyCommissioner = commissioners.some(
|
|
|
|
|
|
(c) => c.userId === member.clerkId
|
|
|
|
|
|
);
|
|
|
|
|
|
return (
|
|
|
|
|
|
<SelectItem
|
|
|
|
|
|
key={member.id}
|
|
|
|
|
|
value={member.clerkId}
|
|
|
|
|
|
disabled={alreadyCommissioner}
|
|
|
|
|
|
>
|
|
|
|
|
|
{member.name || "Unknown"}
|
|
|
|
|
|
{alreadyCommissioner && " (already commissioner)"}
|
|
|
|
|
|
</SelectItem>
|
|
|
|
|
|
);
|
2026-03-18 00:53:40 -07:00
|
|
|
|
})}
|
2026-02-20 08:45:09 -08:00
|
|
|
|
</SelectContent>
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="submit"
|
|
|
|
|
|
variant="outline"
|
|
|
|
|
|
size="sm"
|
|
|
|
|
|
disabled={navigation.state === "submitting"}
|
|
|
|
|
|
>
|
|
|
|
|
|
Add
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
2025-10-11 00:29:04 -07:00
|
|
|
|
{/* Danger Zone */}
|
|
|
|
|
|
<Card className="border-destructive">
|
|
|
|
|
|
<CardHeader>
|
|
|
|
|
|
<CardTitle className="text-destructive">Danger Zone</CardTitle>
|
|
|
|
|
|
<CardDescription>
|
|
|
|
|
|
Irreversible and destructive actions
|
|
|
|
|
|
</CardDescription>
|
|
|
|
|
|
</CardHeader>
|
2025-10-21 22:34:26 -07:00
|
|
|
|
<CardContent className="space-y-4">
|
|
|
|
|
|
{/* Reset Draft - Admin Only */}
|
2025-10-24 21:41:30 -07:00
|
|
|
|
{isAdmin && season && (
|
2025-10-21 22:34:26 -07:00
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h3 className="font-medium">Reset Draft</h3>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
2025-10-24 21:41:30 -07:00
|
|
|
|
Delete all draft picks, queues, and timers, and reset the season to pre-draft status. The draft order will be preserved.
|
2025-10-21 22:34:26 -07:00
|
|
|
|
</p>
|
|
|
|
|
|
<AlertDialog>
|
|
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button variant="destructive" className="w-full">
|
|
|
|
|
|
Reset Draft
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Reset the draft?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
2025-10-24 21:41:30 -07:00
|
|
|
|
This will delete all draft picks, draft queues, and draft timers, and set the season back to pre-draft status.
|
2025-10-21 22:34:26 -07:00
|
|
|
|
The draft order will remain intact. This action cannot be undone.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<Form method="post">
|
|
|
|
|
|
<input type="hidden" name="intent" value="reset-draft" />
|
|
|
|
|
|
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
|
|
|
|
|
Reset Draft
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* Delete League */}
|
|
|
|
|
|
<div className="space-y-2">
|
|
|
|
|
|
<h3 className="font-medium">Delete League</h3>
|
|
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
|
Permanently delete this league and all associated data.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
<AlertDialogTrigger asChild>
|
|
|
|
|
|
<Button variant="destructive" className="w-full">
|
|
|
|
|
|
Delete League
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</AlertDialogTrigger>
|
|
|
|
|
|
<AlertDialogContent>
|
|
|
|
|
|
<AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
|
|
|
|
|
|
<AlertDialogDescription>
|
|
|
|
|
|
This will permanently delete <strong>{league.name}</strong> and all
|
|
|
|
|
|
associated data including seasons, teams, and commissioners. This
|
|
|
|
|
|
action cannot be undone.
|
|
|
|
|
|
</AlertDialogDescription>
|
|
|
|
|
|
</AlertDialogHeader>
|
|
|
|
|
|
<AlertDialogFooter>
|
|
|
|
|
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
|
|
|
|
<Form method="post" onSubmit={handleDelete}>
|
|
|
|
|
|
<input type="hidden" name="intent" value="delete" />
|
|
|
|
|
|
<AlertDialogAction type="submit" className="bg-destructive hover:bg-destructive/90">
|
|
|
|
|
|
Delete League
|
|
|
|
|
|
</AlertDialogAction>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</AlertDialogFooter>
|
|
|
|
|
|
</AlertDialogContent>
|
|
|
|
|
|
</AlertDialog>
|
2025-10-21 22:34:26 -07:00
|
|
|
|
</div>
|
2025-10-11 00:29:04 -07:00
|
|
|
|
</CardContent>
|
|
|
|
|
|
</Card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|