2025-10-14 22:45:24 -07:00
import { useState } from "react" ;
import { Form , redirect , Link } 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" ;
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" ;
2025-10-13 19:59:34 -07:00
import { findActiveSeasonTemplates , findSeasonTemplateWithSportsSeasons } from "~/models/season-template" ;
2026-03-18 22:40:19 -07:00
import { findDraftableSportsSeasons } from "~/models/sports-season" ;
2025-10-14 22:45:24 -07:00
import { generateUniqueTeamNames } from "~/utils/team-names" ;
2025-10-15 22:02:21 -07:00
import { format } from "date-fns" ;
import { CalendarIcon , Check } from "lucide-react" ;
2025-10-11 00:07:39 -07:00
import { Button } from "~/components/ui/button" ;
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" ;
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 { Calendar } from "~/components/ui/calendar" ;
import {
Popover ,
PopoverContent ,
PopoverTrigger ,
} from "~/components/ui/popover" ;
import { cn } from "~/lib/utils" ;
2026-03-20 21:36:39 -07:00
import { parseDraftSpeed } from "~/lib/draft-timer" ;
2025-10-29 00:04:27 -07:00
import { ScoringRulesEditor } from "~/components/scoring/ScoringRulesEditor" ;
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" } ] ;
}
2025-10-13 19:59:34 -07:00
export async function loader() {
const templates = await findActiveSeasonTemplates ( ) ;
2026-03-18 22:40:19 -07:00
const allSportsSeasons = await findDraftableSportsSeasons ( ) ;
2025-10-13 19:59:34 -07:00
// Fetch templates with their sports seasons for mapping
const templatesWithSports = await Promise . all (
templates . map ( async ( template ) = > {
const templateWithSports = await findSeasonTemplateWithSportsSeasons ( template . id ) ;
return {
. . . template ,
sportsSeasonIds : templateWithSports?.seasonTemplateSports?.map (
2026-03-21 09:44:05 -07:00
( ts : { sportsSeason : { id : string } } ) = > ts . sportsSeason . id
2025-10-13 19:59:34 -07:00
) || [ ]
} ;
} )
) ;
return {
templates : templatesWithSports ,
allSportsSeasons : allSportsSeasons as Array < typeof allSportsSeasons [ 0 ] & { sport : { id : string ; name : string ; type : string ; slug : string } } >
} ;
}
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 ;
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" ) ;
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" ;
2025-10-20 15:03:11 -07:00
// Map draft speed to time values
2026-03-20 21:36:39 -07:00
const { draftInitialTime : draftInitialTimeNum , draftIncrementTime : draftIncrementTimeNum } =
parseDraftSpeed ( draftSpeed as string | null , draftTimerMode ) ;
2025-10-11 00:07:39 -07:00
// Validation
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
// Extract and validate scoring rules
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 ;
}
}
}
2025-10-11 00:07:39 -07:00
try {
// Create league
const league = await createLeague ( {
name : name.trim ( ) ,
createdBy : userId ,
} ) ;
2025-10-11 00:29:04 -07:00
// Make creator a commissioner
await createCommissioner ( {
leagueId : league.id ,
userId : userId ,
} ) ;
2025-10-11 00:07:39 -07:00
// Create first season
const currentYear = new Date ( ) . getFullYear ( ) ;
const season = await createSeason ( {
leagueId : league.id ,
year : currentYear ,
status : "pre_draft" ,
2025-10-13 19:59:34 -07:00
templateId : typeof templateId === "string" && templateId !== "" ? 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 ,
2025-10-16 00:37:51 -07:00
draftInitialTime : draftInitialTimeNum ,
draftIncrementTime : draftIncrementTimeNum ,
2026-03-20 21:36:39 -07:00
draftTimerMode ,
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
// Set this as the current season for the league
await setCurrentSeason ( league . id , season . id ) ;
2025-10-13 19:59:34 -07:00
// Add selected sports to the season
const selectedSports = formData . getAll ( "sportsSeasons" ) ;
2025-10-15 21:50:02 -07:00
// Validate that draft rounds is at least the number of sports
if ( selectedSports . length > draftRoundsNum ) {
return {
error : ` You need at least ${ selectedSports . length } draft rounds for the ${ selectedSports . length } sports selected. Currently set to ${ draftRoundsNum } rounds. `
} ;
}
2025-10-13 19:59:34 -07:00
if ( selectedSports . length > 0 ) {
await linkMultipleSportsToSeason (
selectedSports . map ( ( id ) = > ( {
seasonId : season.id ,
sportsSeasonId : id as string ,
} ) )
) ;
}
2025-10-14 22:45:24 -07:00
// Create teams with fun random names
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 ) ;
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 ,
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
name : 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 ) ;
// Redirect to league homepage
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." } ;
}
}
2025-10-13 19:59:34 -07:00
export default function NewLeague ( { loaderData , actionData } : Route . ComponentProps ) {
const { templates , allSportsSeasons } = loaderData ;
const [ selectedTemplate , setSelectedTemplate ] = useState < string > ( "" ) ;
const [ selectedSports , setSelectedSports ] = useState < Set < string > > ( new Set ( ) ) ;
2025-10-15 21:50:02 -07:00
const [ draftRounds , setDraftRounds ] = useState < number > ( 20 ) ;
2025-10-15 22:02:21 -07:00
const [ draftDate , setDraftDate ] = useState < Date > ( ) ;
const [ draftTime , setDraftTime ] = useState < string > ( "" ) ;
2026-03-20 21:36:39 -07:00
const [ timerMode , setTimerMode ] = useState < "chess_clock" | "standard" > ( "chess_clock" ) ;
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 ) ;
} ;
const handleTemplateClick = ( templateId : string , sportsSeasonIds : string [ ] ) = > {
setSelectedTemplate ( templateId ) ;
setSelectedSports ( new Set ( sportsSeasonIds ) ) ;
} ;
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-11 00:07:39 -07:00
return (
< div className = "container max-w-2xl mx-auto py-8 px-4" >
< Card >
< CardHeader >
< CardTitle className = "text-3xl" > Start a New League < / CardTitle >
< CardDescription >
Create your fantasy league and invite your friends to compete .
< / CardDescription >
< / CardHeader >
< CardContent >
< Form method = "post" className = "space-y-6" >
< div className = "space-y-2" >
< Label htmlFor = "name" > League Name < / Label >
< Input
id = "name"
name = "name"
type = "text"
placeholder = "Enter league name"
required
minLength = { 3 }
maxLength = { 50 }
/ >
< / div >
< div className = "space-y-2" >
< Label htmlFor = "teamCount" > Number of Teams < / Label >
< Select name = "teamCount" defaultValue = "12" required >
< SelectTrigger id = "teamCount" >
< SelectValue placeholder = "Select number of teams" / >
< / SelectTrigger >
< SelectContent >
< SelectItem value = "6" > 6 Teams < / SelectItem >
< SelectItem value = "7" > 7 Teams < / SelectItem >
< SelectItem value = "8" > 8 Teams < / SelectItem >
< SelectItem value = "9" > 9 Teams < / SelectItem >
< SelectItem value = "10" > 10 Teams < / SelectItem >
< SelectItem value = "11" > 11 Teams < / SelectItem >
< SelectItem value = "12" > 12 Teams < / SelectItem >
< SelectItem value = "13" > 13 Teams < / SelectItem >
< SelectItem value = "14" > 14 Teams < / SelectItem >
< SelectItem value = "15" > 15 Teams < / SelectItem >
< SelectItem value = "16" > 16 Teams < / SelectItem >
< / SelectContent >
< / Select >
< p className = "text-sm text-muted-foreground" >
Choose between 6 and 16 teams
< / p >
< / div >
2025-10-15 21:50:02 -07:00
< div className = "space-y-2" >
< Label htmlFor = "draftRounds" > Draft Rounds < / Label >
< Select
name = "draftRounds"
value = { draftRounds . toString ( ) }
onValueChange = { ( value ) = > setDraftRounds ( parseInt ( value ) ) }
required
>
< SelectTrigger id = "draftRounds" >
< SelectValue / >
< / SelectTrigger >
< SelectContent >
{ Array . from ( { length : 50 } , ( _ , i ) = > i + 1 )
. filter ( num = > num >= minRounds )
. map ( ( num ) = > (
< SelectItem
key = { num }
value = { num . toString ( ) }
>
{ num } Round { num !== 1 ? 's' : '' }
{ num === recommendedRounds && ' (recommended)' }
< / SelectItem >
) ) }
< / SelectContent >
< / Select >
< div className = "space-y-1" >
< p className = "text-sm text-muted-foreground" >
Minimum : { minRounds } ( number of sports selected )
{ recommendedRounds > minRounds && ` • Recommended: ${ recommendedRounds } ` }
< / p >
{ 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 >
) }
< / div >
< / 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"
) }
>
< 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 }
initialFocus
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"
/ >
< / div >
{ draftDate && draftTime && (
< input
type = "hidden"
name = "draftDateTime"
value = { new Date ( ` ${ format ( draftDate , "yyyy-MM-dd" ) } T ${ draftTime } ` ) . toISOString ( ) }
/ >
) }
< p className = "text-sm text-muted-foreground" >
You must set a draft date and time before starting the draft
< / p >
< / div >
2026-03-20 21:36:39 -07:00
< div className = "space-y-2" >
< Label htmlFor = "draftTimerMode" > Timer Mode < / Label >
< select
id = "draftTimerMode"
name = "draftTimerMode"
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"
value = { timerMode }
onChange = { ( e ) = > setTimerMode ( e . target . value as "chess_clock" | "standard" ) }
required
>
< option value = "chess_clock" > Chess Clock ( accumulating bank ) < / option >
< option value = "standard" > Standard ( per - pick countdown ) < / option >
< / select >
< p className = "text-sm text-muted-foreground" >
Chess Clock : unused time carries over between picks . Standard : timer resets each pick .
< / 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"
2026-03-20 21:36:39 -07:00
defaultValue = { timerMode === "standard" ? "90" : "standard" }
key = { timerMode }
2025-10-16 00:37:51 -07:00
required
2025-10-20 15:03:11 -07:00
>
2026-03-20 21:36:39 -07:00
{ timerMode === "standard" ? (
< >
< option value = "15" > 15 seconds < / option >
< option value = "30" > 30 seconds < / option >
< option value = "60" > 1 minute < / option >
< option value = "90" > 90 seconds ( default ) < / option >
< option value = "120" > 2 minutes < / option >
< option value = "900" > 15 minutes < / option >
< option value = "3600" > 1 hour < / option >
< option value = "7200" > 2 hours < / option >
< option value = "14400" > 4 hours < / option >
< option value = "28800" > 8 hours < / option >
< option value = "43200" > 12 hours < / option >
< / >
) : (
< >
< 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 >
< / >
) }
2025-10-20 15:03:11 -07:00
< / select >
2025-10-16 00:37:51 -07:00
< p className = "text-sm text-muted-foreground" >
2026-03-20 21:36:39 -07:00
{ timerMode === "standard"
? "Time per pick — resets to this value after each selection"
: "Initial time bank + increment added after each pick" }
2025-10-16 00:37:51 -07:00
< / p >
< / div >
2025-10-29 00:04:27 -07:00
< ScoringRulesEditor disabled = { false } / >
2025-10-13 19:59:34 -07:00
< div className = "space-y-4" >
< Label > Sports Selection < / Label >
< p className = "text-sm text-muted-foreground" >
Choose a template to auto - select sports , or manually select your own
< / p >
< input type = "hidden" name = "templateId" value = { selectedTemplate } / >
{ /* Template Cards */ }
{ templates . length > 0 && (
< div className = "grid gap-3" >
{ templates . map ( ( template ) = > (
< Card
key = { template . id }
className = { ` cursor-pointer transition-all ${
selectedTemplate === template . id
? "ring-2 ring-primary"
: "hover:border-primary/50"
} ` }
onClick = { ( ) = > handleTemplateClick ( template . id , template . sportsSeasonIds ) }
>
< CardHeader className = "pb-3" >
< div className = "flex items-start justify-between" >
< div className = "flex-1" >
< CardTitle className = "text-lg" > { template . name } < / CardTitle >
< CardDescription > { template . year } < / CardDescription >
< / div >
{ selectedTemplate === template . id && (
< div className = "rounded-full bg-primary text-primary-foreground p-1" >
< Check className = "h-4 w-4" / >
< / div >
) }
< / div >
< / CardHeader >
{ template . description && (
< CardContent className = "pt-0" >
< p className = "text-sm text-muted-foreground" > { template . description } < / p >
< / CardContent >
) }
< / Card >
) ) }
< / div >
) }
{ /* Sports Checkboxes */ }
< 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-64 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 ) }
/ >
< Label
htmlFor = { season . id }
className = "font-normal cursor-pointer flex-1"
>
< 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 >
) }
< / div >
< / div >
< / div >
< / div >
2026-02-20 08:45:09 -08:00
< div className = "flex items-center space-x-2" >
< Checkbox
id = "joinAsPlayer"
name = "joinAsPlayer"
defaultChecked = { true }
/ >
< Label htmlFor = "joinAsPlayer" className = "font-normal cursor-pointer" >
I want to play in this league ( claim a team )
< / Label >
< / div >
2025-10-11 00:07:39 -07:00
{ actionData ? . error && (
< div className = "bg-destructive/15 text-destructive px-4 py-3 rounded-md text-sm" >
{ actionData . error }
< / div >
) }
< div className = "flex gap-4" >
< Button type = "submit" className = "flex-1" >
Create League
< / Button >
< Button type = "button" variant = "outline" asChild >
< Link to = "/" > Cancel < / Link >
< / Button >
< / div >
< / Form >
< / CardContent >
< / Card >
< / div >
) ;
}