Initial commit v1
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user