2026-04-26 22:31:52 -07:00
import { useState , useEffect } from "react" ;
2026-04-28 22:24:13 -07:00
import { redirect , useFetcher } from "react-router" ;
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
import { auth } from "~/lib/auth.server" ;
2026-04-28 22:24:13 -07:00
import { authClient } from "~/lib/auth-client" ;
2025-10-14 22:45:24 -07:00
import type { Route } from "./+types/new" ;
2026-03-10 12:10:52 -07:00
2026-03-21 13:41:39 -07:00
import { logger } from "~/lib/logger" ;
2025-10-11 21:10:01 -07:00
import { createLeague , setCurrentSeason } from "~/models/league" ;
2025-10-11 00:29:04 -07:00
import { createCommissioner } from "~/models/commissioner" ;
2025-10-14 22:45:24 -07:00
import { createSeason } from "~/models/season" ;
2025-10-13 19:59:34 -07:00
import { linkMultipleSportsToSeason } from "~/models/season-sport" ;
2025-10-14 22:45:24 -07:00
import { createManyTeams } from "~/models/team" ;
2026-06-04 21:31:49 +00:00
import { findActiveSeasonTemplates , findSeasonTemplateWithSports } from "~/models/season-template" ;
2026-03-18 22:40:19 -07:00
import { findDraftableSportsSeasons } from "~/models/sports-season" ;
2026-04-28 22:24:13 -07:00
import { findUserById , updateUser } from "~/models/user" ;
2026-04-29 10:03:50 -07:00
import { generateUniqueTeamNames , prependOwnerToTeamName } from "~/utils/team-names" ;
2025-10-15 22:02:21 -07:00
import { format } from "date-fns" ;
2026-05-04 20:31:44 -07:00
import { CalendarClock , Check , Info , Star , Swords , Trophy } from "lucide-react" ;
2025-10-13 19:59:34 -07:00
import { Badge } from "~/components/ui/badge" ;
2026-04-28 22:24:13 -07:00
import { Button } from "~/components/ui/button" ;
import { Card , CardContent } from "~/components/ui/card" ;
2025-10-14 22:45:24 -07:00
import { Input } from "~/components/ui/input" ;
import { Label } from "~/components/ui/label" ;
import { Checkbox } from "~/components/ui/checkbox" ;
2025-10-15 22:02:21 -07:00
import { cn } from "~/lib/utils" ;
2026-04-28 22:24:13 -07:00
import { parseDraftSpeed , formatTime12h } from "~/lib/draft-timer" ;
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect" ;
2026-05-15 16:04:07 -07:00
import { useLocalTimezone } from "~/hooks/useLocalTimezone" ;
2026-04-28 22:24:13 -07:00
import { saveWizardState , buildFormDataFromSnapshot } from "~/lib/wizard-state" ;
import { type ScoringRules , DEFAULT_SCORING_RULES } from "~/lib/scoring-types" ;
import { SportIcon } from "~/components/league/SportIcon" ;
import { TimerModeSelector } from "~/components/league/TimerModeSelector" ;
import { DraftSpeedPicker , formatDraftSpeed , isSlowPreset } from "~/components/league/DraftSpeedPicker" ;
import { OvernightPauseSettings } from "~/components/league/OvernightPauseSettings" ;
import { ScoringPresetPicker , PLACEMENT_LABELS } from "~/components/league/ScoringPresetPicker" ;
import { WizardStepper } from "~/components/league/WizardStepper" ;
import { ReviewSection } from "~/components/league/ReviewSection" ;
import { StepperInput } from "~/components/league/StepperInput" ;
import { WizardAuthForm } from "~/components/league/WizardAuthForm" ;
2026-05-04 20:31:44 -07:00
import {
applyBracktSportsForSeason ,
seedOrRepairBracktTemplate ,
} from "~/services/brackt.server" ;
2026-04-28 22:24:13 -07:00
// ─── Types ────────────────────────────────────────────────────────────────────
type SportSeason = {
id : string ;
name : string ;
year : number ;
scoringType : string ;
2026-05-04 20:31:44 -07:00
fantasySeasonId : string | null ;
2026-04-28 22:24:13 -07:00
sport : { id : string ; name : string ; type : string ; slug : string ; iconUrl : string | null } ;
participantCount : number ;
} ;
type Template = {
id : string ;
name : string ;
description : string | null ;
sportsSeasonIds : string [ ] ;
} ;
2026-05-04 20:31:44 -07:00
const BRACKT_SPORT_TOOLTIP =
"Draft managers from your own league. They score based on how their teams perform across the other sports." ;
2026-04-28 22:24:13 -07:00
2026-05-04 20:31:44 -07:00
function isBracktSport ( sport : { slug : string } ) {
return sport . slug === "brackt" ;
}
function BracktInfoIcon() {
return (
< span
title = { BRACKT_SPORT_TOOLTIP }
aria - label = { BRACKT_SPORT_TOOLTIP }
className = "inline-flex text-muted-foreground"
>
< Info className = "h-3.5 w-3.5" / >
< / span >
) ;
}
2026-04-28 22:24:13 -07:00
function defaultRounds ( count : number ) : number {
if ( count <= 0 ) return 3 ;
const pct = count >= 20 ? 1.25 : 1.33 ;
return Math . max ( count + 3 , Math . ceil ( count * pct ) ) ;
}
// ─── Meta ─────────────────────────────────────────────────────────────────────
2025-10-13 19:59:34 -07:00
2026-03-10 12:10:52 -07:00
export function meta ( ) : Route . MetaDescriptors {
return [ { title : "New League - Brackt" } ] ;
}
2026-04-28 22:24:13 -07:00
// ─── Loader ───────────────────────────────────────────────────────────────────
2026-04-26 22:31:52 -07:00
export async function loader ( args : Route.LoaderArgs ) {
const session = await auth . api . getSession ( { headers : args.request.headers } ) ;
const userId = session ? . user . id ? ? null ;
2026-05-04 20:31:44 -07:00
await seedOrRepairBracktTemplate ( ) ;
2025-10-13 19:59:34 -07:00
const templates = await findActiveSeasonTemplates ( ) ;
2026-03-18 22:40:19 -07:00
const allSportsSeasons = await findDraftableSportsSeasons ( ) ;
2026-04-26 22:31:52 -07:00
2026-06-04 21:31:49 +00:00
const draftableSeasonBySportId = new Map (
allSportsSeasons . map ( ( s ) = > [ s . sportId , s ] )
) ;
2025-10-13 19:59:34 -07:00
const templatesWithSports = await Promise . all (
templates . map ( async ( template ) = > {
2026-06-04 21:31:49 +00:00
const t = await findSeasonTemplateWithSports ( template . id ) ;
2025-10-13 19:59:34 -07:00
return {
. . . template ,
2026-06-04 21:31:49 +00:00
sportsSeasonIds : ( t ? . seasonTemplateSports ? ? [ ] )
. map ( ( ts : { sportId : string } ) = > draftableSeasonBySportId . get ( ts . sportId ) ? . id )
. filter ( ( id ) : id is string = > id !== null && id !== undefined ) ,
2025-10-13 19:59:34 -07:00
} ;
} )
) ;
2026-04-26 22:31:52 -07:00
const commishTimezone = userId
? ( ( await findUserById ( userId ) ) ? . timezone ? ? null )
: null ;
2025-10-13 19:59:34 -07:00
return {
2026-04-28 22:24:13 -07:00
templates : templatesWithSports as Template [ ] ,
allSportsSeasons : allSportsSeasons as SportSeason [ ] ,
2026-04-26 22:31:52 -07:00
commishTimezone ,
2026-04-28 22:24:13 -07:00
isAuthenticated : userId !== null ,
2025-10-13 19:59:34 -07:00
} ;
}
2025-10-11 00:07:39 -07:00
2026-04-28 22:24:13 -07:00
// ─── Action ───────────────────────────────────────────────────────────────────
2025-10-11 00:07:39 -07:00
export async function action ( args : Route.ActionArgs ) {
Migrate authentication from Clerk to BetterAuth (#324)
* Migrate authentication from Clerk to BetterAuth (#322)
Replaces @clerk/react-router with self-hosted better-auth to eliminate
the external Clerk dependency and keep all user/session data in our own
PostgreSQL database.
**What changed**
- New: auth.server.ts (BetterAuth config w/ Drizzle adapter, bcrypt, Resend), auth-client.ts, api.auth.$.ts handler
- New: /login and /register pages with email+password and Google/Discord OAuth; open-redirect guard on redirectTo param
- New: UserMenu component replacing Clerk's UserButton
- Schema: sessions, accounts, verifications tables; emailVerified column; clerkId made nullable
- Migrations 0081 (BetterAuth tables) and 0082 (accounts extra columns for v1.6.9)
- All ~30 route files: getAuth → auth.api.getSession, isUserAdminByClerkId → isUserAdmin
- root.tsx: isAdmin read directly from session.user.isAdmin (no extra DB query)
- useDraftAuthRecovery: removed Clerk JWT refresh logic; replaced with cookie-session check
- models/user.ts: removed findUserByClerkId, findOrCreateUser, updateUserByClerkId (webhook pattern)
- Deleted: app/routes/api/webhooks/clerk.ts; uninstalled @clerk/react-router, @clerk/themes, svix
- scripts/migrate.mjs: extended with idempotent Clerk → BetterAuth data migration (FK conversion, email_verified, OAuth accounts)
- scripts/migrate-clerk-passwords.mjs: one-time script to import bcrypt hashes from Clerk CSV export
- BETTERAUTH_MIGRATION.md: dev and production runbooks
- All test mocks updated: vi.mock('~/lib/auth.server') instead of @clerk/react-router/server
- Test fixtures: added emailVerified field
**Follow-up (post-stable)**
- Rename actor_clerk_id column → actor_user_id in commissioner_audit_log
- Drop clerk_id column from users once migration confirmed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add .npmrc with legacy-peer-deps for better-auth/drizzle peer dep conflict
better-auth@1.6.9 declares peerOptional deps on drizzle-orm ^0.45.2 and
drizzle-kit >=0.31.4, but we run drizzle-orm ~0.36.3 / drizzle-kit ~0.28.1.
The adapter works correctly at runtime with our versions — the peer dep is
only for stricter type checking. This unblocks npm ci in CI without a risky
drizzle major-version upgrade.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-24 22:00:49 -07:00
const session = await auth . api . getSession ( { headers : args.request.headers } ) ;
const userId = session ? . user . id ? ? null ;
2025-10-11 00:07:39 -07:00
const { request } = args ;
2026-04-26 22:31:52 -07:00
2025-10-11 00:07:39 -07:00
if ( ! userId ) {
throw new Response ( "Unauthorized" , { status : 401 } ) ;
}
const formData = await request . formData ( ) ;
const name = formData . get ( "name" ) ;
const teamCount = formData . get ( "teamCount" ) ;
2025-10-13 19:59:34 -07:00
const templateId = formData . get ( "templateId" ) ;
2025-10-15 21:50:02 -07:00
const draftRounds = formData . get ( "draftRounds" ) ;
2025-10-15 22:02:21 -07:00
const draftDateTime = formData . get ( "draftDateTime" ) ;
2026-05-16 23:37:29 -07:00
const autoStartDraft = formData . get ( "autoStartDraft" ) === "on" && ! ! ( typeof draftDateTime === "string" && draftDateTime ) ;
2025-10-20 15:03:11 -07:00
const draftSpeed = formData . get ( "draftSpeed" ) ;
2026-03-20 21:36:39 -07:00
const draftTimerMode = ( formData . get ( "draftTimerMode" ) as "chess_clock" | "standard" ) || "chess_clock" ;
const { draftInitialTime : draftInitialTimeNum , draftIncrementTime : draftIncrementTimeNum } =
parseDraftSpeed ( draftSpeed as string | null , draftTimerMode ) ;
2025-10-11 00:07:39 -07:00
if ( typeof name !== "string" || ! name . trim ( ) ) {
return { error : "League name is required" } ;
}
if ( typeof teamCount !== "string" ) {
return { error : "Number of teams is required" } ;
}
const teamCountNum = parseInt ( teamCount , 10 ) ;
if ( isNaN ( teamCountNum ) || teamCountNum < 6 || teamCountNum > 16 ) {
return { error : "Number of teams must be between 6 and 16" } ;
}
2025-10-15 21:50:02 -07:00
const draftRoundsNum = typeof draftRounds === "string" ? parseInt ( draftRounds , 10 ) : 20 ;
if ( isNaN ( draftRoundsNum ) || draftRoundsNum < 1 || draftRoundsNum > 50 ) {
return { error : "Draft rounds must be between 1 and 50" } ;
}
2025-10-29 00:04:27 -07:00
const scoringFields = [
"pointsFor1st" , "pointsFor2nd" , "pointsFor3rd" , "pointsFor4th" ,
"pointsFor5th" , "pointsFor6th" , "pointsFor7th" , "pointsFor8th"
] ;
const scoringRules : Record < string , number > = { } ;
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 ` } ;
}
scoringRules [ field ] = points ;
}
}
}
2026-04-26 22:31:52 -07:00
const rawPauseMode = formData . get ( "overnightPauseMode" ) as string | null ;
const overnightPauseMode : "none" | "league" | "per_user" =
rawPauseMode === "league" || rawPauseMode === "per_user" ? rawPauseMode : "none" ;
const overnightPauseStart = overnightPauseMode !== "none"
? ( formData . get ( "overnightPauseStart" ) as string | null ) ? ? "23:00"
: null ;
const overnightPauseEnd = overnightPauseMode !== "none"
? ( formData . get ( "overnightPauseEnd" ) as string | null ) ? ? "07:00"
: null ;
const overnightPauseTimezone = overnightPauseMode !== "none"
? ( formData . get ( "overnightPauseTimezone" ) as string | null ) ? ? null
: null ;
2025-10-11 00:07:39 -07:00
try {
const league = await createLeague ( {
name : name.trim ( ) ,
createdBy : userId ,
} ) ;
2025-10-11 00:29:04 -07:00
await createCommissioner ( {
leagueId : league.id ,
userId : userId ,
} ) ;
2025-10-11 00:07:39 -07:00
const currentYear = new Date ( ) . getFullYear ( ) ;
const season = await createSeason ( {
leagueId : league.id ,
year : currentYear ,
status : "pre_draft" ,
2026-05-04 20:31:44 -07:00
templateId : typeof templateId === "string" && templateId !== "" && templateId !== "customize"
? templateId
: null ,
2025-10-15 21:50:02 -07:00
draftRounds : draftRoundsNum ,
2025-10-15 22:02:21 -07:00
draftDateTime : typeof draftDateTime === "string" && draftDateTime ? new Date ( draftDateTime ) : null ,
2026-05-16 23:37:29 -07:00
autoStartDraft ,
2025-10-16 00:37:51 -07:00
draftInitialTime : draftInitialTimeNum ,
draftIncrementTime : draftIncrementTimeNum ,
2026-03-20 21:36:39 -07:00
draftTimerMode ,
2026-04-26 22:31:52 -07:00
overnightPauseMode ,
overnightPauseStart ,
overnightPauseEnd ,
overnightPauseTimezone ,
2025-10-29 00:04:27 -07:00
. . . scoringRules ,
2025-10-11 00:07:39 -07:00
} ) ;
2025-10-11 21:10:01 -07:00
await setCurrentSeason ( league . id , season . id ) ;
2026-05-04 20:31:44 -07:00
const selectedSports = formData . getAll ( "sportsSeasons" ) . map ( String ) ;
2026-04-26 22:31:52 -07:00
2025-10-15 21:50:02 -07:00
if ( selectedSports . length > draftRoundsNum ) {
2026-04-26 22:31:52 -07:00
return {
error : ` You need at least ${ selectedSports . length } draft rounds for the ${ selectedSports . length } sports selected. Currently set to ${ draftRoundsNum } rounds. `
2025-10-15 21:50:02 -07:00
} ;
}
2026-04-26 22:31:52 -07:00
2026-02-20 08:45:09 -08:00
const joinAsPlayer = formData . get ( "joinAsPlayer" ) === "on" ;
2025-10-14 22:45:24 -07:00
const teamNames = generateUniqueTeamNames ( teamCountNum ) ;
2026-04-29 10:03:50 -07:00
const creator = await findUserById ( userId ) ;
const creatorUsername = creator ? . username ? ? creator ? . displayName ;
Fix oxlint warnings: no-shadow, consistent-function-scoping, no-non-null-assertion, and others (#196)
* Fix no-shadow and consistent-function-scoping lint violations
Resolves all 11 no-shadow and 16 consistent-function-scoping oxlint
warnings and promotes both rules to errors in .oxlintrc.json.
no-shadow: renamed Drizzle callback params (sports→s, matches→m,
seasons→s) to avoid shadowing outer imports; removed shadowed
destructures (eq, inArray) from where callbacks; renamed inner
template→bracketTemplate, prev→currentTimers, season→ss, name→teamName
(with name: teamName fix to preserve semantics).
consistent-function-scoping: moved formatDate, getRankBadge,
getMovementIndicator, getPositionBadge, getStatusBadge, toDateStr,
elo (×2), weightedPick, sortByMatchNumber (×2) to module scope;
moved formatTime (×2), isValidLeagueName, getDraftTimes,
makeSeasonQueues to file scope in test files.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix no-non-null-assertion lint violations and promote to error
Eliminates all 208 no-non-null-assertion warnings across 38 files.
Promotes typescript/no-non-null-assertion from warn to error in
.oxlintrc.json.
Fix patterns applied:
- Map.get(key)! after .has() check → extract with get() + null guard
- Map.get(key)! on pre-populated count maps → ?? 0 default
- .set(id, map.get(id)! + 1) increment → ?? 0 before adding
- participant1Id!/participant2Id! on DB matches → ?? "" fallback
- array.find()! in tests → guard + throw or expect().toBeDefined()
- bracketTemplateCache.get(id)! → null guard extract
- Various nullable field accesses → optional chain or ?? default
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix prefer-add-event-listener, no-unassigned-import, require-module-specifiers
Resolves all 9 remaining non-console lint warnings and promotes all
three rules to errors in .oxlintrc.json.
- prefer-add-event-listener: converted onchange/onclick/onload
assignments to addEventListener in useDraftNotifications.ts and
admin.data-sync.tsx; stored changeHandler ref for proper cleanup
with removeEventListener
- no-unassigned-import: configured rule with allow list for legitimate
side-effect imports (*.css, @testing-library/jest-dom,
@testing-library/cypress/add-commands)
- require-module-specifiers: removed redundant `export {}` from
cypress/support/e2e.ts (file already has an import)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Fix TypeScript errors from no-non-null-assertion fixes
Two fixes introduced by the non-null assertion cleanup produced type
errors:
- scoring-event.ts: `?? ""` was wrong type for a participant object map;
restructured to explicit null guards so TypeScript can narrow correctly
- standings-sync/index.ts: `?? null` after name-match lookup lost the
truthy guarantee, causing TS18047 on the write-back block; added
`participant &&` guard before accessing its properties
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* Add npm run typecheck as Stop hook in Claude settings
Runs a full project typecheck at the end of each Claude turn so type
errors surface as feedback before the next message.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 10:59:51 -07:00
const teams = teamNames . map ( ( teamName , i ) = > ( {
2025-10-11 00:07:39 -07:00
seasonId : season.id ,
2026-04-29 10:03:50 -07:00
name : joinAsPlayer && i === 0 && creatorUsername
? prependOwnerToTeamName ( teamName , creatorUsername )
: teamName ,
2026-02-20 08:45:09 -08:00
ownerId : joinAsPlayer && i === 0 ? userId : null ,
2025-10-11 00:07:39 -07:00
} ) ) ;
await createManyTeams ( teams ) ;
2026-05-04 20:31:44 -07:00
const sportsToLink = await applyBracktSportsForSeason ( season . id , selectedSports ) ;
if ( sportsToLink . length > 0 ) {
await linkMultipleSportsToSeason (
sportsToLink . map ( ( id ) = > ( {
seasonId : season.id ,
sportsSeasonId : id ,
} ) )
) ;
}
2026-04-28 22:24:13 -07:00
const userTimezone = formData . get ( "userTimezone" ) ;
if ( typeof userTimezone === "string" && userTimezone ) {
await updateUser ( userId , { timezone : userTimezone } ) ;
}
2025-10-11 00:07:39 -07:00
return redirect ( ` /leagues/ ${ league . id } ` ) ;
} catch ( error ) {
2026-03-21 13:41:39 -07:00
logger . error ( "Error creating league:" , error ) ;
2025-10-11 00:07:39 -07:00
return { error : "Failed to create league. Please try again." } ;
}
}
2026-04-28 22:24:13 -07:00
// ─── Step 1: League Basics ────────────────────────────────────────────────────
2026-04-26 22:31:52 -07:00
2026-04-28 22:24:13 -07:00
function Step1LeagueBasics ( {
leagueName ,
setLeagueName ,
teamCount ,
setTeamCount ,
joinAsPlayer ,
setJoinAsPlayer ,
error ,
onNext ,
} : {
leagueName : string ;
setLeagueName : ( v : string ) = > void ;
teamCount : number ;
setTeamCount : ( v : number ) = > void ;
joinAsPlayer : boolean ;
setJoinAsPlayer : ( v : boolean ) = > void ;
error : string | null ;
onNext : ( ) = > void ;
} ) {
const [ nameTouched , setNameTouched ] = useState ( false ) ;
const nameInvalid = nameTouched && ! leagueName . trim ( ) ;
2025-10-13 19:59:34 -07:00
2026-04-28 22:24:13 -07:00
return (
< div className = "space-y-6" >
< div >
< h2 className = "text-xl font-semibold flex items-center gap-2" >
< span className = "w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0" > 1 < / span >
League Basics
< / h2 >
< / div >
< div className = "space-y-2" >
< Label htmlFor = "name" > League Name < span className = "text-destructive" > * < / span > < / Label >
< Input
id = "name"
type = "text"
placeholder = "e.g. Friday Night Drafters"
value = { leagueName }
onChange = { ( e ) = > setLeagueName ( e . target . value ) }
onBlur = { ( ) = > setNameTouched ( true ) }
minLength = { 3 }
maxLength = { 50 }
required
aria - invalid = { nameInvalid || undefined }
/ >
< / div >
< div className = "space-y-2" >
< Label > Number of Teams < / Label >
< StepperInput
value = { teamCount }
min = { 6 }
max = { 16 }
onChange = { setTeamCount }
decrementLabel = "Decrease team count"
incrementLabel = "Increase team count"
/ >
< p className = "text-sm text-muted-foreground" > Choose between 6 and 16 teams < / p >
< / div >
< div className = "flex items-center gap-3" >
< Checkbox
id = "commissionerOnly"
checked = { ! joinAsPlayer }
onCheckedChange = { ( v ) = > setJoinAsPlayer ( v !== true ) }
/ >
< Label htmlFor = "commissionerOnly" className = "font-normal cursor-pointer text-muted-foreground" >
Commissioner only — I won & apos ; t be managing a team
< / Label >
< / div >
{ error && (
< p className = "text-sm text-destructive" > { error } < / p >
) }
< div className = "grid grid-cols-2 gap-3 pt-2" >
< Button type = "button" variant = "outline" disabled className = "w-full" >
← Back
< / Button >
< Button type = "button" onClick = { onNext } className = "w-full" disabled = { ! leagueName . trim ( ) } >
NEXT →
< / Button >
< / div >
< / div >
) ;
}
// ─── Step 2: Sports ───────────────────────────────────────────────────────────
function Step2Sports ( {
templates ,
allSportsSeasons ,
teamCount ,
selectedTemplate ,
setSelectedTemplate ,
selectedSports ,
setSelectedSports ,
error ,
onNext ,
onBack ,
} : {
templates : Template [ ] ;
allSportsSeasons : SportSeason [ ] ;
teamCount : number ;
selectedTemplate : string ;
setSelectedTemplate : ( v : string ) = > void ;
selectedSports : Set < string > ;
setSelectedSports : ( v : Set < string > ) = > void ;
error : string | null ;
onNext : ( ) = > void ;
onBack : ( ) = > void ;
} ) {
const toggleSport = ( id : string ) = > {
const next = new Set ( selectedSports ) ;
if ( next . has ( id ) ) next . delete ( id ) ;
else next . add ( id ) ;
setSelectedSports ( next ) ;
2025-10-13 19:59:34 -07:00
} ;
2026-04-28 22:24:13 -07:00
const handleTemplateClick = ( id : string , sportsSeasonIds : string [ ] ) = > {
setSelectedTemplate ( id ) ;
2025-10-13 19:59:34 -07:00
setSelectedSports ( new Set ( sportsSeasonIds ) ) ;
} ;
2025-10-15 21:50:02 -07:00
2026-04-28 22:24:13 -07:00
// When a named template is selected, only show that template's seasons
// Always exclude seasons with fewer participants than the league's team count
const activeTemplate = templates . find ( ( t ) = > t . id === selectedTemplate ) ;
2026-05-04 20:31:44 -07:00
const eligibleSeasons = allSportsSeasons . filter (
( s ) = > s . participantCount >= teamCount || ( s . sport . slug === "brackt" && s . fantasySeasonId === null )
) ;
2026-04-28 22:24:13 -07:00
const displaySeasons = selectedTemplate === "customize" || ! activeTemplate
? eligibleSeasons
: eligibleSeasons . filter ( ( s ) = > activeTemplate . sportsSeasonIds . includes ( s . id ) ) ;
2025-10-15 21:50:02 -07:00
2026-05-04 20:31:44 -07:00
const sortedDisplaySeasons = displaySeasons . toSorted ( ( a , b ) = > {
const sportNameDiff = a . sport . name . localeCompare ( b . sport . name ) ;
if ( sportNameDiff !== 0 ) return sportNameDiff ;
return b . year - a . year ;
} ) ;
2025-10-11 00:07:39 -07:00
2026-04-28 22:24:13 -07:00
return (
< div className = "space-y-5" >
< div >
< h2 className = "text-xl font-semibold flex items-center gap-2" >
< span className = "w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0" > 2 < / span >
Sports
< / h2 >
< / div >
2025-10-11 00:07:39 -07:00
2026-04-28 22:24:13 -07:00
{ templates . length > 0 && (
< div className = "space-y-2" >
{ templates . map ( ( t ) = > (
< button
key = { t . id }
type = "button"
onClick = { ( ) = > handleTemplateClick ( t . id , t . sportsSeasonIds ) }
className = { cn (
"w-full text-left rounded-lg border-2 px-4 py-3 flex items-center justify-between transition-all hover:border-primary/60" ,
selectedTemplate === t . id ? "border-primary" : "border-border"
) }
>
< div >
< p className = "font-semibold text-sm" > { t . name } < / p >
{ t . description && (
< p className = "text-xs text-muted-foreground mt-0.5" > { t . description } < / p >
2025-10-15 21:50:02 -07:00
) }
2026-04-28 22:24:13 -07:00
< / div >
< div className = "flex items-center gap-3 shrink-0 ml-4" >
< span className = "text-xs text-muted-foreground" >
{ new Set ( allSportsSeasons . filter ( s = > t . sportsSeasonIds . includes ( s . id ) ) . map ( s = > s . sport . name ) ) . size } sports
< / span >
{ selectedTemplate === t . id && (
< div className = "w-5 h-5 rounded-full bg-primary flex items-center justify-center" >
< Check className = "h-3 w-3 text-primary-foreground" / >
< / div >
2025-10-15 21:50:02 -07:00
) }
< / div >
2026-04-28 22:24:13 -07:00
< / button >
) ) }
< button
type = "button"
onClick = { ( ) = > setSelectedTemplate ( "customize" ) }
className = { cn (
"w-full text-left rounded-lg border-2 px-4 py-3 flex items-center justify-between transition-all hover:border-primary/60" ,
selectedTemplate === "customize" ? "border-primary" : "border-border"
) }
>
< div >
< p className = "font-semibold text-sm" > Customize < / p >
< p className = "text-xs text-muted-foreground mt-0.5" > Pick your own sports seasons < / p >
2025-10-15 21:50:02 -07:00
< / div >
2026-04-28 22:24:13 -07:00
{ selectedTemplate === "customize" && (
< div className = "w-5 h-5 rounded-full bg-primary flex items-center justify-center shrink-0 ml-4" >
< Check className = "h-3 w-3 text-primary-foreground" / >
< / div >
) }
< / button >
< / div >
) }
{ /* Named template: read-only alphabetical sport list */ }
{ selectedTemplate !== "" && selectedTemplate !== "customize" && (
< div className = "space-y-3" >
< ul className = "space-y-1" >
{ Object . values (
displaySeasons . reduce < Record < string , SportSeason [ "sport" ] > > ( ( acc , s ) = > {
acc [ s . sport . name ] = s . sport ;
return acc ;
} , { } )
) . toSorted ( ( a , b ) = > a . name . localeCompare ( b . name ) ) . map ( ( sport ) = > (
2026-05-04 20:31:44 -07:00
< li
key = { sport . name }
className = "flex items-center gap-2 text-sm"
title = { isBracktSport ( sport ) ? BRACKT_SPORT_TOOLTIP : undefined }
>
2026-04-28 22:24:13 -07:00
< SportIcon sport = { sport } / >
< span > { sport . name } < / span >
2026-05-04 20:31:44 -07:00
{ isBracktSport ( sport ) && < BracktInfoIcon / > }
2026-04-28 22:24:13 -07:00
< / li >
) ) }
< / ul >
< p className = "text-xs text-muted-foreground" >
These are the sport seasons included in this template . Switch to Customize to adjust your selection .
< / p >
< / div >
) }
2025-10-15 21:50:02 -07:00
2026-04-28 22:24:13 -07:00
{ /* Customize: full interactive selector */ }
{ selectedTemplate === "customize" && (
< div className = "space-y-3" >
{ /* Selected chips */ }
{ selectedSports . size > 0 && (
< div className = "space-y-1.5" >
< div className = "flex items-center justify-between" >
< p className = "text-xs text-muted-foreground" > { selectedSports . size } selected < / p >
< button
type = "button"
onClick = { ( ) = > setSelectedSports ( new Set ( ) ) }
className = "text-xs text-muted-foreground underline underline-offset-2 hover:text-foreground"
>
Clear all
< / button >
< / div >
< div className = "flex flex-wrap gap-1.5" >
{ allSportsSeasons
. filter ( ( s ) = > selectedSports . has ( s . id ) )
. toSorted ( ( a , b ) = > a . sport . name . localeCompare ( b . sport . name ) )
. map ( ( s ) = > (
< span
key = { s . id }
className = "inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md bg-primary/10 border border-primary text-primary text-sm font-medium"
2025-10-15 22:02:21 -07:00
>
2026-04-28 22:24:13 -07:00
< SportIcon sport = { s . sport } / >
2026-05-15 16:04:07 -07:00
{ s . name }
2026-04-28 22:24:13 -07:00
< button
type = "button"
onClick = { ( ) = > toggleSport ( s . id ) }
className = "ml-0.5 hover:text-primary/60 transition-colors"
aria - label = { ` Remove ${ s . sport . name } ` }
>
×
< / button >
< / span >
) ) }
2025-10-15 22:02:21 -07:00
< / div >
< / div >
2026-04-28 22:24:13 -07:00
) }
2025-10-15 22:02:21 -07:00
2026-04-28 22:24:13 -07:00
{ /* Full sport picker */ }
2026-05-04 20:31:44 -07:00
< div className = "max-h-72 overflow-y-auto pr-1" >
< div className = "flex flex-col gap-1" >
{ sortedDisplaySeasons . map ( ( s ) = > {
const selected = selectedSports . has ( s . id ) ;
return (
< button
key = { s . id }
type = "button"
onClick = { ( ) = > toggleSport ( s . id ) }
title = { isBracktSport ( s . sport ) ? BRACKT_SPORT_TOOLTIP : undefined }
className = { cn (
"w-full flex items-center justify-between px-3 py-2 rounded-md text-sm border transition-colors" ,
selected
? "bg-primary/10 border-primary text-primary font-medium"
: "bg-muted border-border text-foreground hover:border-primary/50"
) }
>
< span className = "flex items-center gap-2" >
< SportIcon sport = { s . sport } / >
2026-05-15 16:04:07 -07:00
{ s . name }
2026-05-04 20:31:44 -07:00
{ isBracktSport ( s . sport ) && < BracktInfoIcon / > }
< / span >
< span className = "text-xs font-semibold" > { selected ? "Remove" : "Add" } < / span >
< / button >
) ;
} ) }
{ sortedDisplaySeasons . length === 0 && (
2026-04-28 22:24:13 -07:00
< p className = "text-sm text-muted-foreground" > No sports seasons available . < / p >
) }
2026-03-20 21:36:39 -07:00
< / div >
2026-04-28 22:24:13 -07:00
< / div >
< / div >
) }
2026-03-20 21:36:39 -07:00
2026-04-28 22:24:13 -07:00
{ error && < p className = "text-sm text-destructive" > { error } < / p > }
2025-10-16 00:37:51 -07:00
2026-04-28 22:24:13 -07:00
< div className = "grid grid-cols-2 gap-3 pt-2" >
< Button type = "button" variant = "outline" onClick = { onBack } className = "w-full" >
← Back
< / Button >
< Button type = "button" onClick = { onNext } className = "w-full" >
NEXT →
< / Button >
< / div >
2026-04-26 22:31:52 -07:00
2026-04-28 22:24:13 -07:00
{ selectedSports . size === 0 && (
< p className = "text-center text-sm text-muted-foreground" >
Select at least one sport season to continue .
< / p >
) }
< / div >
) ;
}
2026-04-26 22:31:52 -07:00
2026-04-28 22:24:13 -07:00
// ─── Step 3: Draft Settings ───────────────────────────────────────────────────
2026-04-26 22:31:52 -07:00
2026-04-28 22:24:13 -07:00
function Step3DraftSettings ( {
draftRounds ,
setDraftRounds ,
draftDate ,
setDraftDate ,
draftTime ,
setDraftTime ,
2026-05-16 23:37:29 -07:00
autoStartDraft ,
setAutoStartDraft ,
2026-04-28 22:24:13 -07:00
timerMode ,
setTimerMode ,
draftSpeed ,
setDraftSpeed ,
overnightMode ,
setOvernightMode ,
overnightStart ,
setOvernightStart ,
overnightEnd ,
setOvernightEnd ,
overnightTimezone ,
setOvernightTimezone ,
commishTimezone ,
sportsCount ,
error ,
onNext ,
onBack ,
} : {
draftRounds : number ;
setDraftRounds : ( v : number ) = > void ;
draftDate : string ;
setDraftDate : ( v : string ) = > void ;
draftTime : string ;
setDraftTime : ( v : string ) = > void ;
2026-05-16 23:37:29 -07:00
autoStartDraft : boolean ;
setAutoStartDraft : ( v : boolean ) = > void ;
2026-04-28 22:24:13 -07:00
timerMode : "chess_clock" | "standard" ;
setTimerMode : ( v : "chess_clock" | "standard" ) = > void ;
draftSpeed : string ;
setDraftSpeed : ( v : string ) = > void ;
overnightMode : "none" | "league" | "per_user" ;
setOvernightMode : ( v : "none" | "league" | "per_user" ) = > void ;
overnightStart : string ;
setOvernightStart : ( v : string ) = > void ;
overnightEnd : string ;
setOvernightEnd : ( v : string ) = > void ;
overnightTimezone : string ;
setOvernightTimezone : ( v : string ) = > void ;
commishTimezone : string | null ;
sportsCount : number ;
error : string | null ;
onNext : ( ) = > void ;
onBack : ( ) = > void ;
} ) {
const minRounds = sportsCount ;
const flexSpots = Math . max ( 0 , draftRounds - sportsCount ) ;
const { draftInitialTime , draftIncrementTime } = parseDraftSpeed ( draftSpeed , timerMode ) ;
const showOvernightPause = draftInitialTime >= 3600 || draftIncrementTime >= 600 ;
2026-04-26 22:31:52 -07:00
2026-05-15 16:04:07 -07:00
const localTz = useLocalTimezone ( ) ;
2026-04-28 22:24:13 -07:00
useEffect ( ( ) = > {
if ( ! showOvernightPause ) setOvernightMode ( "none" ) ;
} , [ showOvernightPause ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
2025-10-13 19:59:34 -07:00
2026-04-28 22:24:13 -07:00
return (
< div className = "space-y-8" >
< div >
< h2 className = "text-xl font-semibold flex items-center gap-2" >
< span className = "w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0" > 3 < / span >
Draft Settings
< / h2 >
< / div >
{ /* Draft Rounds */ }
< div className = "space-y-2" >
< Label > Draft Rounds < / Label >
< StepperInput
value = { draftRounds }
min = { Math . max ( 1 , minRounds ) }
max = { 50 }
onChange = { setDraftRounds }
decrementLabel = "Decrease rounds"
incrementLabel = "Increase rounds"
/ >
< p className = "text-sm text-muted-foreground" >
Minimum : { minRounds } ( based on sports selected )
{ sportsCount > 0 && ` · Flex spots: ${ flexSpots } ` }
< / p >
< / div >
{ /* Draft Date & Time */ }
< div className = "space-y-2" >
< Label > Draft Date & amp ; Time < / Label >
< div className = "grid grid-cols-2 gap-2" >
< Input
type = "date"
value = { draftDate }
onChange = { ( e ) = > setDraftDate ( e . target . value ) }
min = { new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) }
/ >
< Input
type = "time"
value = { draftTime }
onChange = { ( e ) = > setDraftTime ( e . target . value ) }
/ >
< / div >
< p className = "text-sm text-muted-foreground" >
This can be changed before the draft starts .
2026-05-15 16:04:07 -07:00
{ localTz && < > Times are in your local timezone ( { localTz } ) . < / > }
2026-04-28 22:24:13 -07:00
< / p >
2026-05-16 23:37:29 -07:00
{ draftDate && draftTime && (
< div className = "flex items-center gap-3 pt-1" >
< Checkbox
id = "autoStartDraft"
checked = { autoStartDraft }
onCheckedChange = { ( v ) = > setAutoStartDraft ( v === true ) }
/ >
< Label htmlFor = "autoStartDraft" className = "font-normal cursor-pointer" >
Auto - start at scheduled time
< / Label >
< / div >
) }
< input type = "hidden" name = "autoStartDraft" value = { draftDate && draftTime && autoStartDraft ? "on" : "" } / >
2026-04-28 22:24:13 -07:00
< / div >
{ /* Timer Mode */ }
< div className = "space-y-2" >
< Label > Timer Mode < / Label >
< TimerModeSelector value = { timerMode } onChange = { setTimerMode } / >
< / div >
{ /* Draft Speed */ }
< div className = "space-y-2" >
< Label > Draft Speed < / Label >
< DraftSpeedPicker timerMode = { timerMode } value = { draftSpeed } onChange = { setDraftSpeed } / >
< / div >
{ /* Overnight Pause */ }
< OvernightPauseSettings
show = { showOvernightPause }
mode = { overnightMode }
onModeChange = { setOvernightMode }
start = { overnightStart }
onStartChange = { setOvernightStart }
end = { overnightEnd }
onEndChange = { setOvernightEnd }
timezone = { overnightTimezone }
onTimezoneChange = { setOvernightTimezone }
commishTimezone = { commishTimezone }
/ >
{ error && < p className = "text-sm text-destructive" > { error } < / p > }
< div className = "grid grid-cols-2 gap-3 pt-2" >
< Button type = "button" variant = "outline" onClick = { onBack } className = "w-full" >
← Back
< / Button >
< Button type = "button" onClick = { onNext } className = "w-full" disabled = { ! draftDate || ! draftTime } >
NEXT →
< / Button >
< / div >
< / div >
) ;
}
// ─── Step 4: Scoring ──────────────────────────────────────────────────────────
function Step4Scoring ( {
scoringPreset ,
setScoringPreset ,
scoringRules ,
setScoringRules ,
onNext ,
onBack ,
} : {
scoringPreset : "brackt" | "omnifantasy" | "custom" ;
setScoringPreset : ( v : "brackt" | "omnifantasy" | "custom" ) = > void ;
scoringRules : ScoringRules ;
setScoringRules : ( v : ScoringRules ) = > void ;
onNext : ( ) = > void ;
onBack : ( ) = > void ;
} ) {
return (
< div className = "space-y-5" >
< div >
< h2 className = "text-xl font-semibold flex items-center gap-2" >
< span className = "w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0" > 4 < / span >
Scoring
< / h2 >
< p className = "text-sm text-muted-foreground mt-1" >
Points awarded to teams based on how their drafted participants finish in each sport & apos ; s season .
< / p >
< / div >
< ScoringPresetPicker
preset = { scoringPreset }
onPresetChange = { setScoringPreset }
rules = { scoringRules }
onRulesChange = { setScoringRules }
/ >
< div className = "grid grid-cols-2 gap-3 pt-2" >
< Button type = "button" variant = "outline" onClick = { onBack } className = "w-full" >
← Back
< / Button >
< Button type = "button" onClick = { onNext } className = "w-full" >
NEXT →
< / Button >
< / div >
< / div >
) ;
}
// ─── Step 5: Review ───────────────────────────────────────────────────────────
function StepReview ( {
leagueName ,
teamCount ,
joinAsPlayer ,
selectedSports ,
allSportsSeasons ,
draftRounds ,
draftDate ,
draftTime ,
timerMode ,
draftSpeed ,
overnightMode ,
overnightStart ,
overnightEnd ,
overnightTimezone ,
scoringPreset ,
scoringRules ,
isAuthenticated ,
authTab ,
setAuthTab ,
displayName ,
setDisplayName ,
authEmail ,
setAuthEmail ,
authPassword ,
setAuthPassword ,
userTimezone ,
setUserTimezone ,
onSocialLogin ,
error ,
submitting ,
onSubmit ,
onBack ,
onEditStep ,
} : {
leagueName : string ;
teamCount : number ;
joinAsPlayer : boolean ;
selectedSports : Set < string > ;
allSportsSeasons : SportSeason [ ] ;
draftRounds : number ;
draftDate : string ;
draftTime : string ;
timerMode : "chess_clock" | "standard" ;
draftSpeed : string ;
overnightMode : "none" | "league" | "per_user" ;
overnightStart : string ;
overnightEnd : string ;
overnightTimezone : string ;
scoringPreset : "brackt" | "omnifantasy" | "custom" ;
scoringRules : ScoringRules ;
isAuthenticated : boolean ;
authTab : "create" | "login" ;
setAuthTab : ( v : "create" | "login" ) = > void ;
displayName : string ;
setDisplayName : ( v : string ) = > void ;
authEmail : string ;
setAuthEmail : ( v : string ) = > void ;
authPassword : string ;
setAuthPassword : ( v : string ) = > void ;
userTimezone : string ;
setUserTimezone : ( v : string ) = > void ;
onSocialLogin : ( provider : "google" | "discord" ) = > void ;
error : string | null ;
submitting : boolean ;
onSubmit : ( ) = > void ;
onBack : ( ) = > void ;
onEditStep : ( n : number ) = > void ;
} ) {
const selectedSeasons = allSportsSeasons . filter ( ( s ) = > selectedSports . has ( s . id ) ) ;
const draftDateTimeStr =
draftDate && draftTime
? ` ${ format ( new Date ( draftDate + "T00:00:00" ) , "MMMM do, yyyy" ) } at ${ formatTime12h ( draftTime ) } `
: "Not set (required before starting draft)" ;
const tzLabel = TIMEZONE_OPTIONS . flatMap ( ( g ) = > g . zones ) . find ( ( z ) = > z . value === overnightTimezone ) ? . label ? ? overnightTimezone ;
const overnightModeLine = overnightMode === "none" ? "None" : overnightMode === "league" ? "League-wide" : "Per user" ;
const overnightTimeLine = overnightMode !== "none" ? ` ${ formatTime12h ( overnightStart ) } – ${ formatTime12h ( overnightEnd ) } ( ${ tzLabel } ) ` : null ;
const maxPoints = Math . max ( . . . PLACEMENT_LABELS . map ( ( { key } ) = > scoringRules [ key ] ) ) ;
const uniqueSportsCount = new Set ( selectedSeasons . map ( ( s ) = > s . sport . id ) ) . size ;
return (
< div className = "space-y-4" >
< div >
< h2 className = "text-xl font-semibold flex items-center gap-2" >
< span className = "w-7 h-7 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-sm font-bold shrink-0" > 5 < / span >
Review
< / h2 >
< p className = "text-sm text-muted-foreground mt-1" >
Everything look good ? You can edit any section before creating .
< / p >
< / div >
< ReviewSection icon = { Swords } title = "League Basics" onEdit = { ( ) = > onEditStep ( 1 ) } >
< div className = "flex items-center justify-between gap-3" >
< p className = "text-lg font-bold text-foreground truncate" > { leagueName } < / p >
< div className = "flex items-center gap-2 shrink-0" >
< Badge variant = "secondary" > { teamCount } teams < / Badge >
{ ! joinAsPlayer && < Badge variant = "default" > Commish only < / Badge > }
< / div >
< / div >
< / ReviewSection >
< ReviewSection icon = { Trophy } title = "Sports" onEdit = { ( ) = > onEditStep ( 2 ) } >
< p className = "text-sm text-muted-foreground" >
< span className = "text-primary font-semibold" > { uniqueSportsCount } sport { uniqueSportsCount !== 1 ? "s" : "" } < / span >
< / p >
< ul className = "mt-1 space-y-1" >
2026-05-15 16:04:07 -07:00
{ selectedSeasons . toSorted ( ( a , b ) = > a . name . localeCompare ( b . name ) ) . map ( ( s ) = > (
2026-04-28 22:24:13 -07:00
< li key = { s . id } className = "flex items-center gap-2 text-sm text-foreground" >
< SportIcon sport = { s . sport } / >
2026-05-15 16:04:07 -07:00
{ s . name }
2026-04-28 22:24:13 -07:00
< / li >
) ) }
< / ul >
< / ReviewSection >
< ReviewSection icon = { CalendarClock } title = "Draft Settings" onEdit = { ( ) = > onEditStep ( 3 ) } >
< dl className = "grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm" >
< dt className = "text-muted-foreground" > Rounds < / dt >
< dd className = "text-foreground" > { draftRounds } < / dd >
< dt className = "text-muted-foreground" > Date & amp ; Time < / dt >
< dd className = "text-primary font-medium" > { draftDateTimeStr } < / dd >
< dt className = "text-muted-foreground" > Timer < / dt >
< dd className = "text-foreground" > { timerMode === "chess_clock" ? "Chess Clock" : "Standard" } < / dd >
< dt className = "text-muted-foreground" > Speed < / dt >
< dd className = "text-foreground" > { formatDraftSpeed ( draftSpeed , timerMode ) } < / dd >
< dt className = "text-muted-foreground" > Pause < / dt >
< dd className = "text-foreground" >
{ overnightModeLine }
{ overnightTimeLine && < div className = "text-muted-foreground text-xs mt-0.5" > { overnightTimeLine } < / div > }
< / dd >
< / dl >
< / ReviewSection >
< ReviewSection icon = { Star } title = "Scoring" onEdit = { ( ) = > onEditStep ( 4 ) } >
< p className = "text-xs text-muted-foreground mb-2" >
{ scoringPreset === "brackt" ? "Brackt Default" : scoringPreset === "omnifantasy" ? "Omnifantasy Default" : "Custom" }
< / p >
< div className = "grid grid-cols-2 gap-x-4 gap-y-2" >
{ PLACEMENT_LABELS . map ( ( { key , label , color } ) = > {
const val = scoringRules [ key ] ;
const pct = maxPoints > 0 ? ( val / maxPoints ) * 100 : 0 ;
return (
< div key = { key } className = "flex items-center gap-2" >
< span className = { cn ( "text-xs font-semibold w-6 shrink-0" , color ) } > { label } < / span >
< div className = "flex-1 h-1.5 bg-muted rounded-full overflow-hidden" >
< div className = "h-full bg-primary rounded-full" style = { { width : ` ${ pct } % ` } } / >
2025-10-13 19:59:34 -07:00
< / div >
2026-04-28 22:24:13 -07:00
< span className = "text-xs font-semibold w-6 text-right shrink-0" > { val } < / span >
2025-10-13 19:59:34 -07:00
< / div >
2026-04-28 22:24:13 -07:00
) ;
} ) }
< / div >
< / ReviewSection >
2025-10-13 19:59:34 -07:00
2026-04-28 22:24:13 -07:00
{ ! isAuthenticated && (
< WizardAuthForm
authTab = { authTab }
setAuthTab = { setAuthTab }
displayName = { displayName }
setDisplayName = { setDisplayName }
authEmail = { authEmail }
setAuthEmail = { setAuthEmail }
authPassword = { authPassword }
setAuthPassword = { setAuthPassword }
userTimezone = { userTimezone }
setUserTimezone = { setUserTimezone }
onSocialLogin = { onSocialLogin }
submitting = { submitting }
/ >
) }
2026-02-20 08:45:09 -08:00
2026-04-28 22:24:13 -07:00
{ error && < p className = "text-sm text-destructive" > { error } < / p > }
< div className = "grid grid-cols-2 gap-3 pt-2" >
< Button type = "button" variant = "outline" onClick = { onBack } disabled = { submitting } className = "w-full" >
← Back
< / Button >
< Button type = "button" onClick = { onSubmit } disabled = { submitting } className = "w-full" >
{ submitting
? "Creating…"
: isAuthenticated
? "CREATE LEAGUE"
: authTab === "create"
? "CREATE ACCOUNT & LEAGUE"
: "LOG IN & CREATE LEAGUE" }
< / Button >
< / div >
< / div >
) ;
}
// ─── Main Component ───────────────────────────────────────────────────────────
export default function NewLeague ( { loaderData } : Route . ComponentProps ) {
const { templates , allSportsSeasons , commishTimezone , isAuthenticated } = loaderData ;
const fetcher = useFetcher ( ) ;
// Wizard
const [ step , setStep ] = useState ( 1 ) ;
// Step-level validation error
const [ stepError , setStepError ] = useState < string | null > ( null ) ;
// Step 1
const [ leagueName , setLeagueName ] = useState ( "" ) ;
const [ teamCount , setTeamCount ] = useState ( 12 ) ;
const [ joinAsPlayer , setJoinAsPlayer ] = useState ( true ) ;
// Step 2
const [ selectedTemplate , setSelectedTemplate ] = useState ( "" ) ;
const [ selectedSports , setSelectedSports ] = useState < Set < string > > ( new Set ( ) ) ;
// Step 3
const [ draftRounds , setDraftRounds ] = useState ( 20 ) ;
const [ draftDate , setDraftDate ] = useState ( "" ) ;
const [ draftTime , setDraftTime ] = useState ( "" ) ;
2026-05-16 23:37:29 -07:00
const [ autoStartDraft , setAutoStartDraft ] = useState ( false ) ;
2026-04-28 22:24:13 -07:00
const [ timerMode , setTimerMode ] = useState < "chess_clock" | "standard" > ( "chess_clock" ) ;
const [ draftSpeed , setDraftSpeed ] = useState ( "standard" ) ;
const [ overnightMode , setOvernightMode ] = useState < "none" | "league" | "per_user" > ( "none" ) ;
const [ overnightStart , setOvernightStart ] = useState ( "23:00" ) ;
const [ overnightEnd , setOvernightEnd ] = useState ( "07:00" ) ;
const [ overnightTimezone , setOvernightTimezone ] = useState ( commishTimezone ? ? "" ) ;
// Step 4
const [ scoringPreset , setScoringPreset ] = useState < "brackt" | "omnifantasy" | "custom" > ( "brackt" ) ;
const [ scoringRules , setScoringRules ] = useState < ScoringRules > ( { . . . DEFAULT_SCORING_RULES } ) ;
// Step 5
const [ authTab , setAuthTab ] = useState < "create" | "login" > ( "create" ) ;
const [ displayName , setDisplayName ] = useState ( "" ) ;
const [ authEmail , setAuthEmail ] = useState ( "" ) ;
const [ authPassword , setAuthPassword ] = useState ( "" ) ;
const [ authError , setAuthError ] = useState < string | null > ( null ) ;
const [ authLoading , setAuthLoading ] = useState ( false ) ;
const [ userTimezone , setUserTimezone ] = useState ( "" ) ;
// Detect browser timezone on mount
useEffect ( ( ) = > {
const detected = Intl . DateTimeFormat ( ) . resolvedOptions ( ) . timeZone ;
if ( ! overnightTimezone ) setOvernightTimezone ( detected ) ;
setUserTimezone ( detected ) ;
} , [ ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
// Recalculate default rounds whenever sports count changes
const sportsCount = selectedSports . size ;
useEffect ( ( ) = > {
setDraftRounds ( defaultRounds ( sportsCount ) ) ;
} , [ sportsCount ] ) ; // eslint-disable-line react-hooks/exhaustive-deps
const isSubmitting = fetcher . state !== "idle" || authLoading ;
const submitError =
fetcher . data && typeof fetcher . data === "object" && "error" in fetcher . data
? String ( ( fetcher . data as { error : unknown } ) . error )
: null ;
function buildFormData ( ) : FormData {
return buildFormDataFromSnapshot ( {
leagueName , teamCount , joinAsPlayer , selectedTemplate ,
selectedSports : [ . . . selectedSports ] ,
2026-05-16 23:37:29 -07:00
draftRounds , draftDate , draftTime , autoStartDraft , timerMode , draftSpeed ,
2026-04-28 22:24:13 -07:00
overnightMode , overnightStart , overnightEnd , overnightTimezone ,
scoringPreset , scoringRules , userTimezone ,
} ) ;
}
async function handleFinalSubmit() {
if ( isAuthenticated ) {
fetcher . submit ( buildFormData ( ) , { method : "post" } ) ;
return ;
}
setAuthError ( null ) ;
setAuthLoading ( true ) ;
if ( authTab === "create" ) {
const { error } = await authClient . signUp . email ( {
name : displayName ,
email : authEmail ,
password : authPassword ,
} ) ;
if ( error ) {
setAuthError ( error . message ? ? "Registration failed. Please try again." ) ;
setAuthLoading ( false ) ;
return ;
}
} else {
const { error } = await authClient . signIn . email ( {
email : authEmail ,
password : authPassword ,
} ) ;
if ( error ) {
setAuthError ( error . message ? ? "Sign in failed. Please check your credentials." ) ;
setAuthLoading ( false ) ;
return ;
}
}
fetcher . submit ( buildFormData ( ) , { method : "post" } ) ;
setAuthLoading ( false ) ;
}
async function handleSocialLogin ( provider : "google" | "discord" ) {
saveWizardState ( {
leagueName , teamCount , joinAsPlayer , selectedTemplate ,
selectedSports : [ . . . selectedSports ] ,
2026-05-16 23:37:29 -07:00
draftRounds , draftDate , draftTime , autoStartDraft , timerMode , draftSpeed ,
2026-04-28 22:24:13 -07:00
overnightMode , overnightStart , overnightEnd , overnightTimezone ,
scoringPreset , scoringRules , userTimezone ,
} ) ;
await authClient . signIn . social ( { provider , callbackURL : "/leagues/creating" } ) ;
}
function goNext() {
setStepError ( null ) ;
if ( step === 1 && leagueName . trim ( ) . length < 3 ) {
setStepError ( "League name must be at least 3 characters." ) ;
return ;
}
if ( step === 2 && selectedSports . size === 0 ) {
setStepError ( "Please select at least one sport season." ) ;
return ;
}
if ( step === 3 && ( ! draftDate || ! draftTime ) ) {
setStepError ( "Please set a draft date and time." ) ;
return ;
}
if ( step === 3 && draftRounds < sportsCount ) {
setStepError ( ` Draft rounds must be at least ${ sportsCount } (number of sports selected). ` ) ;
return ;
}
setStep ( ( s ) = > s + 1 ) ;
}
function goBack() {
setStepError ( null ) ;
setStep ( ( s ) = > s - 1 ) ;
}
function goToStep ( n : number ) {
if ( n < step ) {
setStepError ( null ) ;
setStep ( n ) ;
}
}
const STEPS = [ "Basics" , "Sports" , "Draft Settings" , "Scoring" , "Review" ] ;
return (
< div className = "min-h-screen bg-background" >
< div className = "max-w-lg mx-auto px-4 py-4" >
< div className = "mb-6" >
< h1 className = "text-3xl font-bold" > Start a New League < / h1 >
< p className = "text-muted-foreground mt-1" >
Create your fantasy league and invite your friends to compete .
< / p >
< / div >
< WizardStepper currentStep = { step } steps = { STEPS } onStepClick = { goToStep } / >
< Card className = "py-0" >
< CardContent className = "p-5" >
{ step === 1 && (
< Step1LeagueBasics
leagueName = { leagueName }
setLeagueName = { setLeagueName }
teamCount = { teamCount }
setTeamCount = { setTeamCount }
joinAsPlayer = { joinAsPlayer }
setJoinAsPlayer = { setJoinAsPlayer }
error = { stepError }
onNext = { goNext }
/ >
) }
{ step === 2 && (
< Step2Sports
templates = { templates }
allSportsSeasons = { allSportsSeasons }
teamCount = { teamCount }
selectedTemplate = { selectedTemplate }
setSelectedTemplate = { setSelectedTemplate }
selectedSports = { selectedSports }
setSelectedSports = { setSelectedSports }
error = { stepError }
onNext = { goNext }
onBack = { goBack }
/ >
) }
{ step === 3 && (
< Step3DraftSettings
draftRounds = { draftRounds }
setDraftRounds = { setDraftRounds }
draftDate = { draftDate }
setDraftDate = { setDraftDate }
draftTime = { draftTime }
setDraftTime = { setDraftTime }
2026-05-16 23:37:29 -07:00
autoStartDraft = { autoStartDraft }
setAutoStartDraft = { setAutoStartDraft }
2026-04-28 22:24:13 -07:00
timerMode = { timerMode }
setTimerMode = { ( mode ) = > {
setTimerMode ( mode ) ;
setDraftSpeed ( mode === "chess_clock" ? "standard" : "90" ) ;
} }
draftSpeed = { draftSpeed }
setDraftSpeed = { ( newSpeed ) = > {
if ( isSlowPreset ( newSpeed ) && ! isSlowPreset ( draftSpeed ) ) {
setOvernightMode ( "league" ) ;
setOvernightStart ( "23:00" ) ;
setOvernightEnd ( "07:00" ) ;
}
setDraftSpeed ( newSpeed ) ;
} }
overnightMode = { overnightMode }
setOvernightMode = { setOvernightMode }
overnightStart = { overnightStart }
setOvernightStart = { setOvernightStart }
overnightEnd = { overnightEnd }
setOvernightEnd = { setOvernightEnd }
overnightTimezone = { overnightTimezone }
setOvernightTimezone = { setOvernightTimezone }
commishTimezone = { commishTimezone }
sportsCount = { sportsCount }
error = { stepError }
onNext = { goNext }
onBack = { goBack }
/ >
2025-10-11 00:07:39 -07:00
) }
2026-04-28 22:24:13 -07:00
{ step === 4 && (
< Step4Scoring
scoringPreset = { scoringPreset }
setScoringPreset = { setScoringPreset }
scoringRules = { scoringRules }
setScoringRules = { setScoringRules }
onNext = { goNext }
onBack = { goBack }
/ >
) }
{ step === 5 && (
< StepReview
leagueName = { leagueName }
teamCount = { teamCount }
joinAsPlayer = { joinAsPlayer }
selectedSports = { selectedSports }
allSportsSeasons = { allSportsSeasons }
draftRounds = { draftRounds }
draftDate = { draftDate }
draftTime = { draftTime }
timerMode = { timerMode }
draftSpeed = { draftSpeed }
overnightMode = { overnightMode }
overnightStart = { overnightStart }
overnightEnd = { overnightEnd }
overnightTimezone = { overnightTimezone }
scoringPreset = { scoringPreset }
scoringRules = { scoringRules }
isAuthenticated = { isAuthenticated }
authTab = { authTab }
setAuthTab = { setAuthTab }
displayName = { displayName }
setDisplayName = { setDisplayName }
authEmail = { authEmail }
setAuthEmail = { setAuthEmail }
authPassword = { authPassword }
setAuthPassword = { setAuthPassword }
userTimezone = { userTimezone }
setUserTimezone = { setUserTimezone }
onSocialLogin = { handleSocialLogin }
error = { submitError ? ? authError }
submitting = { isSubmitting }
onSubmit = { handleFinalSubmit }
onBack = { goBack }
onEditStep = { goToStep }
/ >
) }
< / CardContent >
< / Card >
2025-10-11 00:07:39 -07:00
2026-04-28 22:24:13 -07:00
< / div >
2025-10-11 00:07:39 -07:00
< / div >
) ;
}