Initial commit v1

This commit is contained in:
Rolf
2026-03-18 12:37:17 +01:00
commit 4897d3003f
55 changed files with 14241 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
type Round = {
id: string
name: string | null
date: string
holes_count: number
status: string
course: { name: string } | null
tee: { name: string; color: string } | null
}
export function JoinRound({ round, userId }: { round: Round; userId: string }) {
const router = useRouter()
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleJoin() {
setLoading(true)
setError(null)
const supabase = createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
// Get current user's handicap for snapshot
const { data: profile } = await db
.from('profiles')
.select('handicap_index')
.eq('id', userId)
.single()
// Get tee data for course handicap
const { data: tee } = await db
.from('tees')
.select('course_rating, slope_rating')
.eq('id', round.id) // note: round.tee.id would be better — using server action is cleaner
.single()
const { data: roundData } = await db
.from('rounds')
.select('tee_id, course:courses(par)')
.eq('id', round.id)
.single()
const { data: teeData } = roundData?.tee_id
? await db
.from('tees')
.select('course_rating, slope_rating')
.eq('id', roundData.tee_id)
.single()
: { data: null }
const handicapIndex = profile?.handicap_index ?? null
const courseHandicap =
handicapIndex !== null && teeData && roundData?.course
? Math.round(
handicapIndex * (teeData.slope_rating / 113) +
(teeData.course_rating - roundData.course.par),
)
: null
const { error: joinError } = await db.from('round_players').insert({
round_id: round.id,
user_id: userId,
role: 'player',
handicap_index: handicapIndex,
course_handicap: courseHandicap,
})
if (joinError) {
setError(joinError.message)
setLoading(false)
return
}
// Mark invite as accepted
await db
.from('round_invites')
.update({ accepted_at: new Date().toISOString() })
.eq('round_id', round.id)
.eq('email', (await supabase.auth.getUser()).data.user?.email)
router.push(`/rounds/${round.id}`)
}
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm space-y-6">
<div className="space-y-1 text-center">
<div className="text-4xl"></div>
<h1 className="text-xl font-bold">You&apos;re invited!</h1>
</div>
<div className="rounded-xl border p-4 space-y-2">
<div className="font-semibold">{round.course?.name}</div>
<div className="text-sm text-muted-foreground space-y-1">
{round.name && <div>{round.name}</div>}
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full border"
style={{ backgroundColor: round.tee?.color }}
/>
{round.tee?.name} tees · {round.holes_count} holes
</div>
<div>{new Date(round.date).toLocaleDateString()}</div>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button className="w-full" onClick={handleJoin} disabled={loading}>
{loading ? 'Joining…' : 'Join round'}
</Button>
</div>
</div>
)
}
+312
View File
@@ -0,0 +1,312 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import Link from 'next/link'
import { createRoundWithInvites } from '@/app/actions/rounds'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ChevronLeft, Plus, X } from 'lucide-react'
type Tee = {
id: string
name: string
color: string
course_rating: number
slope_rating: number
}
type Course = {
id: string
name: string
par: number
tees: Tee[]
}
type WizardData = {
courseId: string
teeId: string
date: string
holesCount: 9 | 18
name: string
emails: string[]
}
export function NewRoundWizard({ courses }: { courses: Course[] }) {
const router = useRouter()
const searchParams = useSearchParams()
const preselectedCourseId = searchParams.get('course_id') ?? ''
const [step, setStep] = useState(preselectedCourseId ? 2 : 1)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [emailInput, setEmailInput] = useState('')
const [data, setData] = useState<WizardData>({
courseId: preselectedCourseId,
teeId: '',
date: new Date().toISOString().split('T')[0],
holesCount: 18,
name: '',
emails: [],
})
const selectedCourse = courses.find((c) => c.id === data.courseId)
const steps = ['Course', 'Tee', 'Details', 'Invite']
function addEmail() {
const email = emailInput.trim().toLowerCase()
if (!email || data.emails.includes(email)) return
setData({ ...data, emails: [...data.emails, email] })
setEmailInput('')
}
function removeEmail(email: string) {
setData({ ...data, emails: data.emails.filter((e) => e !== email) })
}
async function handleCreate() {
setLoading(true)
setError(null)
const result = await createRoundWithInvites({
course_id: data.courseId,
tee_id: data.teeId,
date: data.date,
holes_count: data.holesCount,
name: data.name || undefined,
emails: data.emails,
})
if ('error' in result && result.error) {
setError(result.error)
setLoading(false)
return
}
if (result.failedInvites?.length) {
setError(`Round created. Could not invite: ${result.failedInvites.join(', ')}`)
}
router.push(`/rounds/${result.roundId}`)
}
return (
<div className="flex min-h-screen flex-col">
{/* Header */}
<div className="flex items-center gap-3 border-b p-4">
<button onClick={() => (step > 1 ? setStep(step - 1) : router.back())}>
<ChevronLeft className="h-5 w-5" />
</button>
<h1 className="font-semibold">New round</h1>
<div className="ml-auto flex gap-1">
{steps.map((s, i) => (
<div
key={s}
className={`h-1.5 w-8 rounded-full transition-colors ${
i + 1 <= step ? 'bg-primary' : 'bg-muted'
}`}
/>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{/* Step 1: Course selection */}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Select course</h2>
{courses.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
No courses yet.{' '}
<Link
href="/courses/new?returnTo=/rounds/new"
className="text-primary underline"
>
Create one first.
</Link>
</p>
) : (
<div className="space-y-2">
{courses.map((course) => (
<button
key={course.id}
onClick={() => {
setData({ ...data, courseId: course.id, teeId: '' })
setStep(2)
}}
className={`w-full rounded-xl border p-4 text-left transition-colors hover:bg-muted ${
data.courseId === course.id ? 'border-primary' : ''
}`}
>
<div className="font-medium">{course.name}</div>
<div className="text-sm text-muted-foreground">
Par {course.par} · {course.tees.length} tee
{course.tees.length !== 1 ? 's' : ''}
</div>
</button>
))}
</div>
)}
<Link
href="/courses/new?returnTo=/rounds/new"
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed p-4 text-sm text-muted-foreground hover:bg-muted"
>
<Plus className="h-4 w-4" />
Add new course
</Link>
</div>
)}
{/* Step 2: Tee selection */}
{step === 2 && selectedCourse && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Select tees</h2>
<p className="text-sm text-muted-foreground">{selectedCourse.name}</p>
<div className="space-y-2">
{selectedCourse.tees.map((tee) => (
<button
key={tee.id}
onClick={() => {
setData({ ...data, teeId: tee.id })
setStep(3)
}}
className={`w-full rounded-xl border p-4 text-left transition-colors hover:bg-muted ${
data.teeId === tee.id ? 'border-primary' : ''
}`}
>
<div className="flex items-center gap-3">
<div
className="h-4 w-4 shrink-0 rounded-full border"
style={{ backgroundColor: tee.color }}
/>
<div>
<div className="font-medium">{tee.name}</div>
<div className="text-sm text-muted-foreground">
Rating {tee.course_rating} · Slope {tee.slope_rating}
</div>
</div>
</div>
</button>
))}
</div>
</div>
)}
{/* Step 3: Round details */}
{step === 3 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Round details</h2>
<div className="space-y-2">
<Label>Number of holes</Label>
<div className="flex gap-2">
{([9, 18] as const).map((n) => (
<button
key={n}
onClick={() => setData({ ...data, holesCount: n })}
className={`flex-1 rounded-lg border py-3 text-sm font-medium transition-colors ${
data.holesCount === n
? 'border-primary bg-primary text-primary-foreground'
: 'border-border hover:bg-muted'
}`}
>
{n} holes
</button>
))}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="round-date">Date</Label>
<Input
id="round-date"
type="date"
value={data.date}
onChange={(e) => setData({ ...data, date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="round-name">
Round name{' '}
<span className="text-muted-foreground font-normal">(optional)</span>
</Label>
<Input
id="round-name"
value={data.name}
onChange={(e) => setData({ ...data, name: e.target.value })}
placeholder="e.g. Sunday morning four-ball"
/>
</div>
<Button className="w-full" onClick={() => setStep(4)}>
Next: Invite players
</Button>
</div>
)}
{/* Step 4: Invite players */}
{step === 4 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Invite players</h2>
<p className="text-sm text-muted-foreground">
Each player will receive a magic link by email.{' '}
<span className="font-medium">You can skip this and invite later from the lobby.</span>
</p>
{/* Email input */}
<div className="flex gap-2">
<Input
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addEmail()}
placeholder="player@example.com"
className="flex-1"
/>
<Button variant="outline" onClick={addEmail} disabled={!emailInput}>
<Plus className="h-4 w-4" />
</Button>
</div>
{/* Email list */}
{data.emails.length > 0 && (
<div className="space-y-2">
{data.emails.map((email) => (
<div
key={email}
className="flex items-center justify-between rounded-lg border px-3 py-2"
>
<span className="text-sm">{email}</span>
<button onClick={() => removeEmail(email)}>
<X className="h-4 w-4 text-muted-foreground hover:text-destructive" />
</button>
</div>
))}
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
className="w-full"
onClick={handleCreate}
disabled={loading}
>
{loading
? 'Creating…'
: data.emails.length > 0
? `Create round & invite ${data.emails.length} player${data.emails.length !== 1 ? 's' : ''}`
: 'Create round'}
</Button>
</div>
)}
</div>
</div>
)
}
+233
View File
@@ -0,0 +1,233 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { startRound } from '@/app/actions/rounds'
import { inviteFromLobby } from '@/app/actions/invites'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { ChevronLeft, Plus, X } from 'lucide-react'
import Link from 'next/link'
type Player = {
id: string
role: string
handicap_index: number | null
course_handicap: number | null
profile: { id: string; display_name: string; avatar_url: string | null } | null
}
type Invite = {
id: string
email: string
accepted_at: string | null
created_at: string
}
type Round = {
id: string
name: string | null
date: string
status: string
holes_count: number
created_by: string
course: { id: string; name: string; par: number } | null
tee: { id: string; name: string; color: string; course_rating: number; slope_rating: number } | null
}
export function RoundLobby({
round,
players,
pendingInvites,
currentUserId,
isCreator,
}: {
round: Round
players: Player[]
pendingInvites: Invite[]
currentUserId: string
isCreator: boolean
}) {
const router = useRouter()
const [emailInput, setEmailInput] = useState('')
const [inviteError, setInviteError] = useState<string | null>(null)
const [inviting, setInviting] = useState(false)
const [starting, setStarting] = useState(false)
const [startError, setStartError] = useState<string | null>(null)
async function handleInvite() {
const email = emailInput.trim().toLowerCase()
if (!email) return
setInviting(true)
setInviteError(null)
const result = await inviteFromLobby(round.id, email)
if (result.error) {
setInviteError(result.error)
} else {
setEmailInput('')
router.refresh()
}
setInviting(false)
}
async function handleStart() {
setStarting(true)
setStartError(null)
const result = await startRound(round.id)
if (result.error) {
setStartError(result.error)
setStarting(false)
} else {
router.refresh()
}
}
return (
<div className="flex min-h-screen flex-col">
{/* Header */}
<div className="flex items-center gap-3 border-b p-4">
<Link href="/dashboard">
<ChevronLeft className="h-5 w-5" />
</Link>
<div className="flex-1 min-w-0">
<h1 className="truncate font-semibold">
{round.name ?? round.course?.name ?? 'Round'}
</h1>
<p className="text-xs text-muted-foreground">
{round.course?.name} · {round.tee?.name} tees ·{' '}
{round.holes_count} holes ·{' '}
{new Date(round.date).toLocaleDateString()}
</p>
</div>
<span className="shrink-0 rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800">
Lobby
</span>
</div>
<div className="flex-1 space-y-6 overflow-y-auto p-4">
{/* Players */}
<div className="space-y-3">
<h2 className="font-semibold">
Players ({players.length})
</h2>
<div className="space-y-2">
{players.map((p) => (
<div key={p.id} className="flex items-center gap-3 rounded-xl border p-3">
<Avatar
name={p.profile?.display_name ?? '?'}
url={p.profile?.avatar_url}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium truncate">
{p.profile?.display_name}
</span>
{p.profile?.id === currentUserId && (
<span className="text-xs text-muted-foreground">(you)</span>
)}
{p.role === 'admin' && (
<span className="rounded-full bg-muted px-1.5 py-0.5 text-xs">
creator
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{p.handicap_index !== null
? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}`
: 'No handicap'}
</div>
</div>
</div>
))}
</div>
</div>
{/* Pending invites */}
{pendingInvites.length > 0 && (
<div className="space-y-2">
<h3 className="text-sm font-medium text-muted-foreground">
Awaiting ({pendingInvites.length})
</h3>
{pendingInvites.map((invite) => (
<div
key={invite.id}
className="flex items-center gap-2 rounded-xl border border-dashed px-3 py-2"
>
<X className="h-3 w-3 text-muted-foreground" />
<span className="text-sm text-muted-foreground">{invite.email}</span>
</div>
))}
</div>
)}
{/* Invite form (creator only, lobby only) */}
{isCreator && round.status === 'lobby' && (
<div className="space-y-2">
<h3 className="text-sm font-medium">Invite player</h3>
<div className="flex gap-2">
<Input
type="email"
value={emailInput}
onChange={(e) => setEmailInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleInvite()}
placeholder="player@example.com"
className="flex-1"
/>
<Button
variant="outline"
onClick={handleInvite}
disabled={!emailInput || inviting}
>
<Plus className="h-4 w-4" />
</Button>
</div>
{inviteError && (
<p className="text-xs text-destructive">{inviteError}</p>
)}
</div>
)}
{/* Start round button */}
{isCreator && round.status === 'lobby' && (
<div className="space-y-2">
{startError && (
<p className="text-sm text-destructive">{startError}</p>
)}
<Button
className="w-full"
disabled={players.length < 1 || starting}
onClick={handleStart}
>
{starting ? 'Starting…' : 'Start round'}
</Button>
</div>
)}
</div>
</div>
)
}
function Avatar({ name, url }: { name: string; url?: string | null }) {
const initials = name
.split(' ')
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2)
if (url) {
return (
<img
src={url}
alt={name}
className="h-9 w-9 rounded-full object-cover"
/>
)
}
return (
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-semibold text-primary">
{initials}
</div>
)
}