brackt/app/routes/leagues/new.tsx

1312 lines
47 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
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";
import { authClient } from "~/lib/auth-client";
import type { Route } from "./+types/new";
import { logger } from "~/lib/logger";
import { createLeague, setCurrentSeason } from "~/models/league";
import { createCommissioner } from "~/models/commissioner";
import { createSeason } from "~/models/season";
import { linkMultipleSportsToSeason } from "~/models/season-sport";
import { createManyTeams } from "~/models/team";
import { findActiveSeasonTemplates, findSeasonTemplateWithSportsSeasons } from "~/models/season-template";
import { findDraftableSportsSeasons } from "~/models/sports-season";
import { findUserById, updateUser } from "~/models/user";
Migrate auth from Clerk to BetterAuth (#354) * Fix BetterAuth field mapping and add owner-prefixed team names - Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth adapter (was snake_case, causing user inserts to fail); add generateId: "uuid" so PostgreSQL UUID columns accept generated IDs - Add prependOwnerToTeamName / stripOwnerFromTeamName utilities - Invite join, league creation, and settings assign/remove all now manage the owner-prefix on team names atomically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Complete BetterAuth migration: auth flows, rename, and cleanup - Add /forgot-password and /reset-password pages (full password reset flow) - Add 'Forgot password?' link on login page - Fix register.tsx: add explicit window.location fallback after signUp - Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084) and update all references in model, routes, components, and tests - Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs) - Update test fixtures and schema comments to remove Clerk ID references; use UUID-format IDs throughout test data - Update docs/agents/domain-models.md ownerId description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix BetterAuth ID generation, clean up review issues - Set generateId: false so BetterAuth omits id from inserts; add $defaultFn(crypto.randomUUID) to accounts/sessions/verifications so Drizzle fills it in (fixes null id constraint violation on signup) - Move renameTeam to static import; rename before removeTeamOwner so a failed rename leaves owner intact rather than leaving a stale prefix - Clarify stripOwnerFromTeamName toTitleCase assumption in comment - Annotate reset-password token fallback to explain loader guarantee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:03:50 -07:00
import { generateUniqueTeamNames, prependOwnerToTeamName } from "~/utils/team-names";
import { format } from "date-fns";
import { CalendarClock, Check, Info, Star, Swords, Trophy } from "lucide-react";
import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { Card, CardContent } from "~/components/ui/card";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import { Checkbox } from "~/components/ui/checkbox";
import { cn } from "~/lib/utils";
import { parseDraftSpeed, formatTime12h } from "~/lib/draft-timer";
import { TIMEZONE_OPTIONS } from "~/components/league/TimezoneSelect";
import { useLocalTimezone } from "~/hooks/useLocalTimezone";
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";
import {
applyBracktSportsForSeason,
seedOrRepairBracktTemplate,
} from "~/services/brackt.server";
// ─── Types ────────────────────────────────────────────────────────────────────
type SportSeason = {
id: string;
name: string;
year: number;
scoringType: string;
fantasySeasonId: string | null;
sport: { id: string; name: string; type: string; slug: string; iconUrl: string | null };
participantCount: number;
};
type Template = {
id: string;
name: string;
year: number;
description: string | null;
sportsSeasonIds: string[];
};
const BRACKT_SPORT_TOOLTIP =
"Draft managers from your own league. They score based on how their teams perform across the other sports.";
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>
);
}
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 ─────────────────────────────────────────────────────────────────────
export function meta(): Route.MetaDescriptors {
return [{ title: "New League - Brackt" }];
}
// ─── Loader ───────────────────────────────────────────────────────────────────
export async function loader(args: Route.LoaderArgs) {
const session = await auth.api.getSession({ headers: args.request.headers });
const userId = session?.user.id ?? null;
await seedOrRepairBracktTemplate();
const templates = await findActiveSeasonTemplates();
const allSportsSeasons = await findDraftableSportsSeasons();
const templatesWithSports = await Promise.all(
templates.map(async (template) => {
const t = await findSeasonTemplateWithSportsSeasons(template.id);
return {
...template,
sportsSeasonIds: t?.seasonTemplateSports?.map(
Add oxlint linting setup with zero errors (#194) * Add oxlint and fix all lint errors - Install oxlint, add .oxlintrc.json with rules for TypeScript/React - Add npm run lint / lint:fix scripts - Add Claude PostToolUse hook to run oxlint on every edited file - Fix 101 errors: unused vars/imports, eqeqeq, prefer-const, no-new-array - Fix no-array-index-key (use stable keys or suppress positional cases) - Fix exhaustive-deps missing dependency in useEffect - Promote exhaustive-deps and no-array-index-key to errors - Fix Map.get() !== null bug in $leagueId.server.ts (should be !== undefined) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix no-explicit-any warnings and upgrade tsconfig to ES2023 - Replace all `any` types with proper types or `unknown` across ~20 files - Add typed socket payload interfaces in draft route and useDraftSocket - Use any[] with eslint-disable for socket.io callbacks (legitimate escape hatch) - Bump all tsconfigs from ES2022 → ES2023 to support toSorted/toReversed - Fix cascading type errors uncovered by removing any: Map.get narrowing, participant relation types, ChartDataPoint, Partial<NewSeason> indexing - Add ParticipantResultWithParticipant type to participant-result model - Fix test fixtures to match updated interfaces (DraftCell, ParticipantResult) - Fix duplicate getQPStandings import in sportsSeasonId.server.ts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Promote no-explicit-any to error Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 09:44:05 -07:00
(ts: { sportsSeason: { id: string } }) => ts.sportsSeason.id
) ?? [],
};
})
);
const commishTimezone = userId
? ((await findUserById(userId))?.timezone ?? null)
: null;
return {
templates: templatesWithSports as Template[],
allSportsSeasons: allSportsSeasons as SportSeason[],
commishTimezone,
isAuthenticated: userId !== null,
};
}
// ─── Action ───────────────────────────────────────────────────────────────────
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;
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");
const templateId = formData.get("templateId");
const draftRounds = formData.get("draftRounds");
const draftDateTime = formData.get("draftDateTime");
const autoStartDraft = formData.get("autoStartDraft") === "on" && !!(typeof draftDateTime === "string" && draftDateTime);
const draftSpeed = formData.get("draftSpeed");
const draftTimerMode = (formData.get("draftTimerMode") as "chess_clock" | "standard") || "chess_clock";
const { draftInitialTime: draftInitialTimeNum, draftIncrementTime: draftIncrementTimeNum } =
parseDraftSpeed(draftSpeed as string | null, draftTimerMode);
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" };
}
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" };
}
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;
}
}
}
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;
try {
const league = await createLeague({
name: name.trim(),
createdBy: userId,
});
await createCommissioner({
leagueId: league.id,
userId: userId,
});
const currentYear = new Date().getFullYear();
const season = await createSeason({
leagueId: league.id,
year: currentYear,
status: "pre_draft",
templateId: typeof templateId === "string" && templateId !== "" && templateId !== "customize"
? templateId
: null,
draftRounds: draftRoundsNum,
draftDateTime: typeof draftDateTime === "string" && draftDateTime ? new Date(draftDateTime) : null,
autoStartDraft,
draftInitialTime: draftInitialTimeNum,
draftIncrementTime: draftIncrementTimeNum,
draftTimerMode,
overnightPauseMode,
overnightPauseStart,
overnightPauseEnd,
overnightPauseTimezone,
...scoringRules,
});
await setCurrentSeason(league.id, season.id);
const selectedSports = formData.getAll("sportsSeasons").map(String);
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.`
};
}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
const joinAsPlayer = formData.get("joinAsPlayer") === "on";
const teamNames = generateUniqueTeamNames(teamCountNum);
Migrate auth from Clerk to BetterAuth (#354) * Fix BetterAuth field mapping and add owner-prefixed team names - Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth adapter (was snake_case, causing user inserts to fail); add generateId: "uuid" so PostgreSQL UUID columns accept generated IDs - Add prependOwnerToTeamName / stripOwnerFromTeamName utilities - Invite join, league creation, and settings assign/remove all now manage the owner-prefix on team names atomically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Complete BetterAuth migration: auth flows, rename, and cleanup - Add /forgot-password and /reset-password pages (full password reset flow) - Add 'Forgot password?' link on login page - Fix register.tsx: add explicit window.location fallback after signUp - Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084) and update all references in model, routes, components, and tests - Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs) - Update test fixtures and schema comments to remove Clerk ID references; use UUID-format IDs throughout test data - Update docs/agents/domain-models.md ownerId description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix BetterAuth ID generation, clean up review issues - Set generateId: false so BetterAuth omits id from inserts; add $defaultFn(crypto.randomUUID) to accounts/sessions/verifications so Drizzle fills it in (fixes null id constraint violation on signup) - Move renameTeam to static import; rename before removeTeamOwner so a failed rename leaves owner intact rather than leaving a stale prefix - Clarify stripOwnerFromTeamName toTitleCase assumption in comment - Annotate reset-password token fallback to explain loader guarantee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
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) => ({
seasonId: season.id,
Migrate auth from Clerk to BetterAuth (#354) * Fix BetterAuth field mapping and add owner-prefixed team names - Fix auth.server.ts: use camelCase Drizzle field names for BetterAuth adapter (was snake_case, causing user inserts to fail); add generateId: "uuid" so PostgreSQL UUID columns accept generated IDs - Add prependOwnerToTeamName / stripOwnerFromTeamName utilities - Invite join, league creation, and settings assign/remove all now manage the owner-prefix on team names atomically Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Complete BetterAuth migration: auth flows, rename, and cleanup - Add /forgot-password and /reset-password pages (full password reset flow) - Add 'Forgot password?' link on login page - Fix register.tsx: add explicit window.location fallback after signUp - Rename commissionerAuditLog.actorClerkId → actorUserId (migration 0084) and update all references in model, routes, components, and tests - Rewrite docs/agents/auth.md to document BetterAuth (removes all Clerk refs) - Update test fixtures and schema comments to remove Clerk ID references; use UUID-format IDs throughout test data - Update docs/agents/domain-models.md ownerId description Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * Fix BetterAuth ID generation, clean up review issues - Set generateId: false so BetterAuth omits id from inserts; add $defaultFn(crypto.randomUUID) to accounts/sessions/verifications so Drizzle fills it in (fixes null id constraint violation on signup) - Move renameTeam to static import; rename before removeTeamOwner so a failed rename leaves owner intact rather than leaving a stale prefix - Clarify stripOwnerFromTeamName toTitleCase assumption in comment - Annotate reset-password token fallback to explain loader guarantee Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 10:03:50 -07:00
name: joinAsPlayer && i === 0 && creatorUsername
? prependOwnerToTeamName(teamName, creatorUsername)
: teamName,
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08:00
ownerId: joinAsPlayer && i === 0 ? userId : null,
}));
await createManyTeams(teams);
const sportsToLink = await applyBracktSportsForSeason(season.id, selectedSports);
if (sportsToLink.length > 0) {
await linkMultipleSportsToSeason(
sportsToLink.map((id) => ({
seasonId: season.id,
sportsSeasonId: id,
}))
);
}
const userTimezone = formData.get("userTimezone");
if (typeof userTimezone === "string" && userTimezone) {
await updateUser(userId, { timezone: userTimezone });
}
return redirect(`/leagues/${league.id}`);
} catch (error) {
logger.error("Error creating league:", error);
return { error: "Failed to create league. Please try again." };
}
}
// ─── Step 1: League Basics ────────────────────────────────────────────────────
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();
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);
};
const handleTemplateClick = (id: string, sportsSeasonIds: string[]) => {
setSelectedTemplate(id);
setSelectedSports(new Set(sportsSeasonIds));
};
// 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);
const eligibleSeasons = allSportsSeasons.filter(
(s) => s.participantCount >= teamCount || (s.sport.slug === "brackt" && s.fantasySeasonId === null)
);
const displaySeasons = selectedTemplate === "customize" || !activeTemplate
? eligibleSeasons
: eligibleSeasons.filter((s) => activeTemplate.sportsSeasonIds.includes(s.id));
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;
});
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>
{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>
)}
</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>
)}
</div>
</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>
</div>
{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) => (
<li
key={sport.name}
className="flex items-center gap-2 text-sm"
title={isBracktSport(sport) ? BRACKT_SPORT_TOOLTIP : undefined}
>
<SportIcon sport={sport} />
<span>{sport.name}</span>
{isBracktSport(sport) && <BracktInfoIcon />}
</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>
)}
{/* 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"
>
<SportIcon sport={s.sport} />
{s.name}
<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>
))}
</div>
</div>
)}
{/* Full sport picker */}
<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} />
{s.name}
{isBracktSport(s.sport) && <BracktInfoIcon />}
</span>
<span className="text-xs font-semibold">{selected ? "Remove" : "Add"}</span>
</button>
);
})}
{sortedDisplaySeasons.length === 0 && (
<p className="text-sm text-muted-foreground">No sports seasons available.</p>
)}
</div>
</div>
</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" onClick={onBack} className="w-full">
Back
</Button>
<Button type="button" onClick={onNext} className="w-full">
NEXT
</Button>
</div>
{selectedSports.size === 0 && (
<p className="text-center text-sm text-muted-foreground">
Select at least one sport season to continue.
</p>
)}
</div>
);
}
// ─── Step 3: Draft Settings ───────────────────────────────────────────────────
function Step3DraftSettings({
draftRounds,
setDraftRounds,
draftDate,
setDraftDate,
draftTime,
setDraftTime,
autoStartDraft,
setAutoStartDraft,
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;
autoStartDraft: boolean;
setAutoStartDraft: (v: boolean) => void;
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;
const localTz = useLocalTimezone();
useEffect(() => {
if (!showOvernightPause) setOvernightMode("none");
}, [showOvernightPause]); // eslint-disable-line react-hooks/exhaustive-deps
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.
{localTz && <> Times are in your local timezone ({localTz}).</>}
</p>
{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" : ""} />
</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">
{selectedSeasons.toSorted((a, b) => a.name.localeCompare(b.name)).map((s) => (
<li key={s.id} className="flex items-center gap-2 text-sm text-foreground">
<SportIcon sport={s.sport} />
{s.name}
</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}%` }} />
</div>
<span className="text-xs font-semibold w-6 text-right shrink-0">{val}</span>
</div>
);
})}
</div>
</ReviewSection>
{!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}
/>
)}
Add implementation plan for commissioner-without-team feature (#6) * Add implementation plan for commissioner-without-team feature https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to exist without owning a team - Add opt-out checkbox on league creation ("I want to play in this league") so the creator can be commissioner-only without claiming a team - Add Commissioner Management card to league settings with add/remove commissioner UI; guards against removing the last commissioner - Add countCommissionersByLeagueId model helper for the last-commissioner guard - Show "No team" indicator on the league homepage next to commissioners who don't own a team in the current season https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Fix code review issues in commissioner management - Add success/error feedback banners to commissioner add/remove actions - Gate allUsers query to admin-only (prevents exposing all users to non-admin commissioners) - Prevent commissioner self-removal in both action (server) and UI (client) - Add "(you)" label next to current user in commissioner list - Remove all unnecessary `any` type casts in favor of inferred types https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 * Allow commissioners to add league members as co-commissioners Non-admin commissioners can now add users who already own a team in the league as co-commissioners. Admins still see the full user list. Previously the add-commissioner form was admin-only. https://claude.ai/code/session_01NSRMSYtb7jSFbngDS8okn3 --------- Co-authored-by: Claude <noreply@anthropic.com>
2026-02-20 08:45:09 -08: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("");
const [autoStartDraft, setAutoStartDraft] = useState(false);
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],
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
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],
draftRounds, draftDate, draftTime, autoStartDraft, timerMode, draftSpeed,
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}
autoStartDraft={autoStartDraft}
setAutoStartDraft={setAutoStartDraft}
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}
/>
)}
{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>
</div>
</div>
);
}