Initial commit v1
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { Flag, Clock, User, ShieldCheck } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/dashboard', label: 'Rounds', icon: Flag },
|
||||
{ href: '/history', label: 'History', icon: Clock },
|
||||
{ href: '/profile', label: 'Profile', icon: User },
|
||||
]
|
||||
|
||||
export function BottomNav({ isAdmin = false }: { isAdmin?: boolean }) {
|
||||
const pathname = usePathname()
|
||||
|
||||
const items = isAdmin
|
||||
? [...navItems, { href: '/admin', label: 'Admin', icon: ShieldCheck }]
|
||||
: navItems
|
||||
|
||||
return (
|
||||
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t bg-background">
|
||||
<div className="flex h-16">
|
||||
{items.map(({ href, label, icon: Icon }) => {
|
||||
const active = pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={cn(
|
||||
'flex flex-1 flex-col items-center justify-center gap-1 text-xs transition-colors',
|
||||
active
|
||||
? 'text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { strokesReceived, scoreColorClass, formatVsPar } from '@/lib/handicap'
|
||||
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type Props = {
|
||||
holes: Hole[]
|
||||
players: Player[]
|
||||
scores: Record<string, number>
|
||||
onTapScore: (player: Player, hole: Hole) => void
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export function FullGrid({ holes, players, scores, onTapScore, isActive }: Props) {
|
||||
const totalHoles = holes.length
|
||||
const front = holes.filter((h) => h.hole_number <= 9)
|
||||
const back = holes.filter((h) => h.hole_number > 9)
|
||||
const hasBoth = front.length > 0 && back.length > 0
|
||||
|
||||
function getGross(playerId: string, hole: Hole) {
|
||||
return scores[`${playerId}-${hole.id}`] ?? null
|
||||
}
|
||||
|
||||
function getNet(playerId: string, hole: Hole) {
|
||||
const gross = getGross(playerId, hole)
|
||||
if (gross === null) return null
|
||||
const ch = players.find((p) => p.user_id === playerId)?.course_handicap ?? 0
|
||||
return gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
||||
}
|
||||
|
||||
function subtotal(playerId: string, holeSet: Hole[], fn: (p: string, h: Hole) => number | null) {
|
||||
const values = holeSet.map((h) => fn(playerId, h)).filter((v): v is number => v !== null)
|
||||
return values.length === holeSet.length ? values.reduce((a, b) => a + b, 0) : null
|
||||
}
|
||||
|
||||
const parFront = front.reduce((s, h) => s + h.par, 0)
|
||||
const parBack = back.reduce((s, h) => s + h.par, 0)
|
||||
const parTotal = parFront + parBack
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-muted/50 text-muted-foreground">
|
||||
<th className="sticky left-0 z-10 bg-muted/50 px-3 py-2 text-left font-medium min-w-[100px]">
|
||||
Player
|
||||
</th>
|
||||
{front.map((h) => (
|
||||
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
||||
{h.hole_number}
|
||||
</th>
|
||||
))}
|
||||
{hasBoth && (
|
||||
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">Out</th>
|
||||
)}
|
||||
{back.map((h) => (
|
||||
<th key={h.id} className="px-2 py-2 text-center font-medium w-8">
|
||||
{h.hole_number}
|
||||
</th>
|
||||
))}
|
||||
{hasBoth && (
|
||||
<th className="px-2 py-2 text-center font-semibold w-10 bg-muted">In</th>
|
||||
)}
|
||||
<th className="px-2 py-2 text-center font-semibold w-12 bg-muted">Tot</th>
|
||||
<th className="px-2 py-2 text-center font-bold w-12 bg-muted">Net</th>
|
||||
</tr>
|
||||
{/* Par row */}
|
||||
<tr className="bg-muted/20 text-muted-foreground border-b">
|
||||
<td className="sticky left-0 z-10 bg-muted/20 px-3 py-1 text-xs italic">Par</td>
|
||||
{front.map((h) => (
|
||||
<td key={h.id} className="px-2 py-1 text-center">
|
||||
{h.par}
|
||||
</td>
|
||||
))}
|
||||
{hasBoth && (
|
||||
<td className="px-2 py-1 text-center font-medium bg-muted">{parFront}</td>
|
||||
)}
|
||||
{back.map((h) => (
|
||||
<td key={h.id} className="px-2 py-1 text-center">
|
||||
{h.par}
|
||||
</td>
|
||||
))}
|
||||
{hasBoth && (
|
||||
<td className="px-2 py-1 text-center font-medium bg-muted">{parBack}</td>
|
||||
)}
|
||||
<td className="px-2 py-1 text-center font-medium bg-muted">{parTotal}</td>
|
||||
<td className="px-2 py-1 text-center bg-muted" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{players.map((player, pi) => {
|
||||
const frontOut = subtotal(player.user_id, front, getGross)
|
||||
const backIn = subtotal(player.user_id, back, getGross)
|
||||
const total =
|
||||
frontOut !== null && backIn !== null
|
||||
? frontOut + backIn
|
||||
: hasBoth
|
||||
? null
|
||||
: subtotal(player.user_id, holes, getGross)
|
||||
const netTotal = subtotal(player.user_id, holes, getNet)
|
||||
const netVsPar = netTotal !== null ? netTotal - parTotal : null
|
||||
|
||||
return (
|
||||
<tr key={player.user_id} className={pi % 2 === 0 ? '' : 'bg-muted/20'}>
|
||||
<td className="sticky left-0 z-10 bg-background px-3 py-2 font-medium truncate max-w-[100px]">
|
||||
{player.profile?.display_name}
|
||||
</td>
|
||||
|
||||
{front.map((h) => {
|
||||
const gross = getGross(player.user_id, h)
|
||||
const grossVsPar = gross !== null ? gross - h.par : null
|
||||
return (
|
||||
<td
|
||||
key={h.id}
|
||||
className="px-1 py-1.5 text-center"
|
||||
onClick={() => isActive && onTapScore(player, h)}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
|
||||
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{gross ?? '·'}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasBoth && (
|
||||
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
||||
{frontOut ?? '—'}
|
||||
</td>
|
||||
)}
|
||||
|
||||
{back.map((h) => {
|
||||
const gross = getGross(player.user_id, h)
|
||||
const grossVsPar = gross !== null ? gross - h.par : null
|
||||
return (
|
||||
<td
|
||||
key={h.id}
|
||||
className="px-1 py-1.5 text-center"
|
||||
onClick={() => isActive && onTapScore(player, h)}
|
||||
>
|
||||
<span
|
||||
className={`flex h-6 w-6 mx-auto items-center justify-center text-xs cursor-pointer ${
|
||||
gross !== null ? scoreColorClass(grossVsPar) : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{gross ?? '·'}
|
||||
</span>
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
|
||||
{hasBoth && (
|
||||
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
||||
{backIn ?? '—'}
|
||||
</td>
|
||||
)}
|
||||
|
||||
<td className="px-2 py-1.5 text-center font-semibold bg-muted/30">
|
||||
{total ?? '—'}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-center font-bold bg-muted/30">
|
||||
{netVsPar !== null ? formatVsPar(netVsPar) : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
'use client'
|
||||
|
||||
import { useRef } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { strokesReceived, formatVsPar, scoreColorClass } from '@/lib/handicap'
|
||||
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type Props = {
|
||||
hole: Hole
|
||||
holeIndex: number
|
||||
totalHoles: number
|
||||
players: Player[]
|
||||
scores: Record<string, number>
|
||||
runningTotals: Record<string, { net: number; gross: number; holesPlayed: number }>
|
||||
isActive: boolean
|
||||
onPrev: () => void
|
||||
onNext: () => void
|
||||
onTapScore: (player: Player) => void
|
||||
}
|
||||
|
||||
export function HoleView({
|
||||
hole,
|
||||
holeIndex,
|
||||
totalHoles,
|
||||
players,
|
||||
scores,
|
||||
runningTotals,
|
||||
isActive,
|
||||
onPrev,
|
||||
onNext,
|
||||
onTapScore,
|
||||
}: Props) {
|
||||
const touchStartX = useRef<number>(0)
|
||||
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
touchStartX.current = e.touches[0].clientX
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: React.TouchEvent) {
|
||||
const diff = touchStartX.current - e.changedTouches[0].clientX
|
||||
if (diff > 50) onNext()
|
||||
if (diff < -50) onPrev()
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col flex-1"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Hole header */}
|
||||
<div className="flex items-center justify-between border-b bg-muted/30 px-4 py-3">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={holeIndex === 0}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="text-lg font-bold">Hole {hole.hole_number}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Par {hole.par} · SI {hole.stroke_index}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={holeIndex === totalHoles - 1}
|
||||
className="flex h-10 w-10 items-center justify-center rounded-full hover:bg-muted disabled:opacity-30"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Players */}
|
||||
<div className="flex-1 divide-y overflow-y-auto">
|
||||
{players.map((player) => {
|
||||
const gross = scores[`${player.user_id}-${hole.id}`] ?? null
|
||||
const ch = player.course_handicap ?? 0
|
||||
const received = strokesReceived(ch, hole.stroke_index, totalHoles)
|
||||
const net = gross !== null ? gross - received : null
|
||||
const grossVsPar = gross !== null ? gross - hole.par : null
|
||||
const netVsPar = net !== null ? net - hole.par : null
|
||||
|
||||
return (
|
||||
<button
|
||||
key={player.user_id}
|
||||
onClick={() => isActive && onTapScore(player)}
|
||||
disabled={!isActive}
|
||||
className="flex w-full items-center gap-3 px-4 py-4 text-left transition-colors hover:bg-muted/50 active:bg-muted disabled:cursor-default"
|
||||
>
|
||||
{/* Avatar */}
|
||||
<PlayerAvatar
|
||||
name={player.profile?.display_name ?? '?'}
|
||||
url={player.profile?.avatar_url}
|
||||
/>
|
||||
|
||||
{/* Name + running total */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">
|
||||
{player.profile?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{runningTotals[player.user_id]
|
||||
? `${runningTotals[player.user_id].holesPlayed} holes · ${formatVsPar(runningTotals[player.user_id].net)} net`
|
||||
: 'No scores yet'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net score (primary, bold) */}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
{net !== null ? (
|
||||
<>
|
||||
<span
|
||||
className={`flex h-9 w-9 items-center justify-center text-base font-bold ${scoreColorClass(netVsPar)}`}
|
||||
>
|
||||
{net}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{gross} gross
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-lg border-2 border-dashed text-muted-foreground text-sm">
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Hole progress dots */}
|
||||
<div className="flex justify-center gap-1 border-t py-3">
|
||||
{Array.from({ length: totalHoles }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === holeIndex
|
||||
? 'w-4 bg-primary'
|
||||
: 'w-1.5 bg-muted-foreground/30'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerAvatar({ 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-10 w-10 rounded-full object-cover shrink-0" />
|
||||
}
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
'use client'
|
||||
|
||||
import { strokesReceived, formatVsPar } from '@/lib/handicap'
|
||||
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type Props = {
|
||||
holes: Hole[]
|
||||
players: Player[]
|
||||
scores: Record<string, number>
|
||||
}
|
||||
|
||||
export function LeaderboardView({ holes, players, scores }: Props) {
|
||||
const totalHoles = holes.length
|
||||
|
||||
const standings = players
|
||||
.map((player) => {
|
||||
let grossTotal = 0
|
||||
let netTotal = 0
|
||||
let holesPlayed = 0
|
||||
let parPlayed = 0
|
||||
|
||||
for (const hole of holes) {
|
||||
const gross = scores[`${player.user_id}-${hole.id}`]
|
||||
if (gross !== undefined) {
|
||||
const ch = player.course_handicap ?? 0
|
||||
const net = gross - strokesReceived(ch, hole.stroke_index, totalHoles)
|
||||
grossTotal += gross
|
||||
netTotal += net
|
||||
parPlayed += hole.par
|
||||
holesPlayed++
|
||||
}
|
||||
}
|
||||
|
||||
const netVsPar = holesPlayed > 0 ? netTotal - parPlayed : null
|
||||
const grossVsPar = holesPlayed > 0 ? grossTotal - parPlayed : null
|
||||
|
||||
return {
|
||||
player,
|
||||
grossTotal,
|
||||
netTotal,
|
||||
holesPlayed,
|
||||
netVsPar,
|
||||
grossVsPar,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.netVsPar === null && b.netVsPar === null) return 0
|
||||
if (a.netVsPar === null) return 1
|
||||
if (b.netVsPar === null) return -1
|
||||
return a.netVsPar - b.netVsPar || a.grossVsPar! - b.grossVsPar!
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{standings.map(({ player, grossTotal, netVsPar, grossVsPar, holesPlayed }, i) => {
|
||||
const isLead = i === 0 && netVsPar !== null
|
||||
|
||||
return (
|
||||
<div
|
||||
key={player.user_id}
|
||||
className={`flex items-center gap-4 px-4 py-4 ${isLead ? 'bg-primary/5' : ''}`}
|
||||
>
|
||||
{/* Position */}
|
||||
<div
|
||||
className={`w-7 text-center text-sm font-bold ${
|
||||
i === 0 ? 'text-primary' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{holesPlayed > 0 ? i + 1 : '—'}
|
||||
</div>
|
||||
|
||||
{/* Avatar */}
|
||||
<PlayerAvatar
|
||||
name={player.profile?.display_name ?? '?'}
|
||||
url={player.profile?.avatar_url}
|
||||
/>
|
||||
|
||||
{/* Name + holes played */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{player.profile?.display_name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{holesPlayed > 0
|
||||
? `${holesPlayed} of ${totalHoles} holes`
|
||||
: 'No scores yet'}
|
||||
{player.course_handicap !== null &&
|
||||
` · HCP ${player.course_handicap}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Net score (primary, bold) */}
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className={`text-lg font-bold ${
|
||||
netVsPar === null
|
||||
? 'text-muted-foreground'
|
||||
: netVsPar < 0
|
||||
? 'text-red-500'
|
||||
: netVsPar === 0
|
||||
? 'text-foreground'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{netVsPar !== null ? formatVsPar(netVsPar) : '—'}
|
||||
</span>
|
||||
{grossVsPar !== null && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{grossTotal} gross
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerAvatar({ 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-10 w-10 rounded-full object-cover shrink-0" />
|
||||
}
|
||||
return (
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/10 text-sm font-semibold text-primary">
|
||||
{initials}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { Hole, Player } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
hole: Hole | null
|
||||
player: Player | null
|
||||
currentStrokes: number | null
|
||||
onSave: (strokes: number) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ScoreEntrySheet({ open, hole, player, currentStrokes, onSave, onClose }: Props) {
|
||||
const [strokes, setStrokes] = useState<number>(currentStrokes ?? hole?.par ?? 4)
|
||||
const [editingNumber, setEditingNumber] = useState(false)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Reset when a new entry opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setStrokes(currentStrokes ?? hole?.par ?? 4)
|
||||
setEditingNumber(false)
|
||||
}
|
||||
}, [open, hole?.id, player?.user_id])
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNumber) inputRef.current?.select()
|
||||
}, [editingNumber])
|
||||
|
||||
// Lock body scroll when open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = open ? 'hidden' : ''
|
||||
return () => { document.body.style.overflow = '' }
|
||||
}, [open])
|
||||
|
||||
if (!hole || !player) return null
|
||||
|
||||
const par = hole.par
|
||||
const vsPar = strokes - par
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={`fixed inset-0 z-40 bg-black/50 transition-opacity duration-200 ${
|
||||
open ? 'opacity-100' : 'pointer-events-none opacity-0'
|
||||
}`}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sheet */}
|
||||
<div
|
||||
className={`fixed bottom-0 left-0 right-0 z-50 rounded-t-2xl bg-background pb-safe transition-transform duration-300 ${
|
||||
open ? 'translate-y-0' : 'translate-y-full'
|
||||
}`}
|
||||
>
|
||||
{/* Handle */}
|
||||
<div className="flex justify-center pt-3 pb-1">
|
||||
<div className="h-1 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
{/* Player + hole info */}
|
||||
<div className="flex items-center justify-between px-6 py-3 border-b">
|
||||
<div>
|
||||
<div className="font-semibold">{player.profile?.display_name}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`text-sm font-bold ${
|
||||
vsPar < 0 ? 'text-red-500' : vsPar > 0 ? 'text-muted-foreground' : 'text-foreground'
|
||||
}`}
|
||||
>
|
||||
{vsPar === 0 ? 'Par' : vsPar > 0 ? `+${vsPar}` : vsPar}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score input */}
|
||||
<div className="flex items-center justify-center gap-8 py-8">
|
||||
{/* Minus */}
|
||||
<button
|
||||
onClick={() => setStrokes(Math.max(1, strokes - 1))}
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-border text-2xl font-light hover:bg-muted active:scale-95"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
|
||||
{/* Score display / input */}
|
||||
{editingNumber ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="number"
|
||||
value={strokes}
|
||||
min={1}
|
||||
max={20}
|
||||
onChange={(e) => setStrokes(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
onBlur={() => setEditingNumber(false)}
|
||||
className="h-20 w-20 rounded-xl border-2 border-primary bg-background text-center text-4xl font-bold outline-none"
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditingNumber(true)}
|
||||
className="flex h-20 w-20 items-center justify-center rounded-xl border-2 border-transparent text-5xl font-bold transition-colors hover:border-primary"
|
||||
>
|
||||
{strokes}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Plus */}
|
||||
<button
|
||||
onClick={() => setStrokes(Math.min(20, strokes + 1))}
|
||||
className="flex h-14 w-14 items-center justify-center rounded-full border-2 border-border text-2xl font-light hover:bg-muted active:scale-95"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Save */}
|
||||
<div className="px-6 pb-8">
|
||||
<Button className="w-full text-base h-12" onClick={() => onSave(strokes)}>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
import { upsertScore } from '@/app/actions/scores'
|
||||
import { strokesReceived } from '@/lib/handicap'
|
||||
import { HoleView } from './hole-view'
|
||||
import { FullGrid } from './full-grid'
|
||||
import { LeaderboardView } from './leaderboard-view'
|
||||
import { ScoreEntrySheet } from './score-entry-sheet'
|
||||
import { ChevronLeft } from 'lucide-react'
|
||||
import type { Hole, Player, ScoreRow, RoundInfo } from '@/app/(app)/rounds/[id]/scorecard/page'
|
||||
|
||||
type View = 'hole' | 'grid' | 'leaderboard'
|
||||
|
||||
type EntryState = {
|
||||
player: Player
|
||||
hole: Hole
|
||||
}
|
||||
|
||||
function buildScoreMap(rows: ScoreRow[]): Record<string, number> {
|
||||
return Object.fromEntries(rows.map((r) => [`${r.player_id}-${r.hole_id}`, r.strokes]))
|
||||
}
|
||||
|
||||
export function ScorecardView({
|
||||
round,
|
||||
holes,
|
||||
players,
|
||||
initialScores,
|
||||
currentUserId,
|
||||
}: {
|
||||
round: RoundInfo
|
||||
holes: Hole[]
|
||||
players: Player[]
|
||||
initialScores: ScoreRow[]
|
||||
currentUserId: string
|
||||
}) {
|
||||
const router = useRouter()
|
||||
const [scores, setScores] = useState<Record<string, number>>(buildScoreMap(initialScores))
|
||||
const [activeHoleIdx, setActiveHoleIdx] = useState(0)
|
||||
const [view, setView] = useState<View>('hole')
|
||||
const [entry, setEntry] = useState<EntryState | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const isActive = round.status === 'active'
|
||||
const activeHole = holes[activeHoleIdx]
|
||||
|
||||
// ── Realtime subscription ──────────────────────────────────
|
||||
useEffect(() => {
|
||||
const supabase = createClient()
|
||||
const channel = supabase
|
||||
.channel(`scores:${round.id}`)
|
||||
.on(
|
||||
'postgres_changes',
|
||||
{
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'scores',
|
||||
filter: `round_id=eq.${round.id}`,
|
||||
},
|
||||
(payload) => {
|
||||
if (payload.eventType === 'DELETE') return
|
||||
const row = payload.new as ScoreRow
|
||||
setScores((prev) => ({
|
||||
...prev,
|
||||
[`${row.player_id}-${row.hole_id}`]: row.strokes,
|
||||
}))
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
return () => {
|
||||
supabase.removeChannel(channel)
|
||||
}
|
||||
}, [round.id])
|
||||
|
||||
// ── Running totals per player ──────────────────────────────
|
||||
const runningTotals = useMemo(() => {
|
||||
const totals: Record<string, { net: number; gross: number; holesPlayed: number }> = {}
|
||||
for (const player of players) {
|
||||
let net = 0
|
||||
let gross = 0
|
||||
let played = 0
|
||||
const ch = player.course_handicap ?? 0
|
||||
for (const hole of holes) {
|
||||
const g = scores[`${player.user_id}-${hole.id}`]
|
||||
if (g !== undefined) {
|
||||
gross += g
|
||||
net += g - strokesReceived(ch, hole.stroke_index, holes.length)
|
||||
played++
|
||||
}
|
||||
}
|
||||
totals[player.user_id] = { net, gross, holesPlayed: played }
|
||||
}
|
||||
return totals
|
||||
}, [scores, players, holes])
|
||||
|
||||
// ── Score save with optimistic update ─────────────────────
|
||||
async function handleSave(strokes: number) {
|
||||
if (!entry) return
|
||||
setSaving(true)
|
||||
|
||||
const key = `${entry.player.user_id}-${entry.hole.id}`
|
||||
const prev = scores[key]
|
||||
|
||||
// Optimistic update
|
||||
setScores((s) => ({ ...s, [key]: strokes }))
|
||||
setEntry(null)
|
||||
|
||||
const result = await upsertScore(round.id, entry.hole.id, entry.player.user_id, strokes)
|
||||
|
||||
if (result.error) {
|
||||
// Revert
|
||||
setScores((s) => {
|
||||
const next = { ...s }
|
||||
if (prev === undefined) delete next[key]
|
||||
else next[key] = prev
|
||||
return next
|
||||
})
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
function openEntry(player: Player, hole?: Hole) {
|
||||
if (!isActive) return
|
||||
setEntry({ player, hole: hole ?? activeHole })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 border-b px-4 py-3 shrink-0">
|
||||
<Link href={`/rounds/${round.id}`}>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold truncate">
|
||||
{round.name ?? round.course?.name ?? 'Scorecard'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{round.tee?.name} tees · {round.holes_count} holes
|
||||
{!isActive && (
|
||||
<span className="ml-1 text-muted-foreground">(completed)</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{saving && (
|
||||
<span className="text-xs text-muted-foreground animate-pulse">Saving…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* View tabs */}
|
||||
<div className="flex border-b shrink-0">
|
||||
{(['hole', 'grid', 'leaderboard'] as View[]).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setView(v)}
|
||||
className={`flex-1 py-2 text-xs font-medium capitalize transition-colors ${
|
||||
view === v
|
||||
? 'border-b-2 border-primary text-primary'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{v === 'grid' ? 'Full card' : v === 'leaderboard' ? 'Leaderboard' : 'Hole'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{view === 'hole' && activeHole && (
|
||||
<HoleView
|
||||
hole={activeHole}
|
||||
holeIndex={activeHoleIdx}
|
||||
totalHoles={holes.length}
|
||||
players={players}
|
||||
scores={scores}
|
||||
runningTotals={runningTotals}
|
||||
isActive={isActive}
|
||||
onPrev={() => setActiveHoleIdx((i) => Math.max(0, i - 1))}
|
||||
onNext={() => setActiveHoleIdx((i) => Math.min(holes.length - 1, i + 1))}
|
||||
onTapScore={(player) => openEntry(player)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === 'grid' && (
|
||||
<div className="overflow-auto flex-1">
|
||||
<FullGrid
|
||||
holes={holes}
|
||||
players={players}
|
||||
scores={scores}
|
||||
isActive={isActive}
|
||||
onTapScore={(player, hole) => openEntry(player, hole)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view === 'leaderboard' && (
|
||||
<div className="overflow-y-auto flex-1">
|
||||
<LeaderboardView holes={holes} players={players} scores={scores} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Score entry sheet */}
|
||||
<ScoreEntrySheet
|
||||
open={entry !== null}
|
||||
hole={entry?.hole ?? null}
|
||||
player={entry?.player ?? null}
|
||||
currentStrokes={
|
||||
entry ? (scores[`${entry.player.user_id}-${entry.hole.id}`] ?? null) : null
|
||||
}
|
||||
onSave={handleSave}
|
||||
onClose={() => setEntry(null)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client"
|
||||
|
||||
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
||||
outline:
|
||||
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
||||
ghost:
|
||||
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default:
|
||||
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
||||
icon: "size-8",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
||||
"icon-sm":
|
||||
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
||||
"icon-lg": "size-9",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
||||
return (
|
||||
<ButtonPrimitive
|
||||
data-slot="button"
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,20 @@
|
||||
import * as React from "react"
|
||||
import { Input as InputPrimitive } from "@base-ui/react/input"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<InputPrimitive
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({ className, ...props }: React.ComponentProps<"label">) {
|
||||
return (
|
||||
<label
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
Reference in New Issue
Block a user