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
+366
View File
@@ -0,0 +1,366 @@
'use client'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { createCourse } from '@/app/actions/courses'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { ChevronLeft } from 'lucide-react'
const TEE_COLORS = [
{ label: 'Yellow', value: '#EAB308' },
{ label: 'Red', value: '#DC2626' },
{ label: 'White', value: '#F9FAFB' },
{ label: 'Blue', value: '#2563EB' },
{ label: 'Black', value: '#111827' },
{ label: 'Green', value: '#16A34A' },
{ label: 'Gold', value: '#D97706' },
]
type Tee = { name: string; color: string; course_rating: string; slope_rating: string }
type Hole = { hole_number: number; par: number; stroke_index: number }
function defaultHoles(count: number): Hole[] {
return Array.from({ length: count }, (_, i) => ({
hole_number: i + 1,
par: 4,
stroke_index: i + 1,
}))
}
export default function NewCoursePage() {
const router = useRouter()
const searchParams = useSearchParams()
const returnTo = searchParams.get('returnTo') ?? '/rounds/new'
const [step, setStep] = useState(1)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
// Step 1
const [name, setName] = useState('')
const [par, setPar] = useState('72')
const [holesCount, setHolesCount] = useState<9 | 18>(18)
// Step 2
const [tees, setTees] = useState<Tee[]>([])
const [teeForm, setTeeForm] = useState<Tee>({
name: '',
color: '#2563EB',
course_rating: '',
slope_rating: '',
})
// Step 3
const [holes, setHoles] = useState<Hole[]>(defaultHoles(18))
const parSum = holes.slice(0, holesCount).reduce((s, h) => s + h.par, 0)
const parTarget = parseInt(par) || 0
const parValid = parSum === parTarget
function addTee() {
if (!teeForm.name || !teeForm.course_rating || !teeForm.slope_rating) return
setTees([...tees, teeForm])
setTeeForm({ name: '', color: '#2563EB', course_rating: '', slope_rating: '' })
}
function removeTee(i: number) {
setTees(tees.filter((_, idx) => idx !== i))
}
function updateHole(index: number, field: 'par' | 'stroke_index', value: number) {
setHoles(holes.map((h, i) => (i === index ? { ...h, [field]: value } : h)))
}
async function handleSubmit() {
setLoading(true)
setError(null)
const result = await createCourse({
name,
par: parseInt(par),
tees: tees.map((t) => ({
name: t.name,
color: t.color,
course_rating: parseFloat(t.course_rating),
slope_rating: parseInt(t.slope_rating),
})),
holes: holes.slice(0, holesCount).map((h) => ({
hole_number: h.hole_number,
par: h.par,
stroke_index: h.stroke_index,
})),
})
if (result.error) {
setError(result.error)
setLoading(false)
return
}
router.push(`${returnTo}?course_id=${result.courseId}`)
}
const steps = ['Course', 'Tees', 'Holes']
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 course</h1>
<div className="ml-auto flex gap-1">
{steps.map((s, i) => (
<div
key={s}
className={`h-1.5 w-8 rounded-full ${i + 1 <= step ? 'bg-primary' : 'bg-muted'}`}
/>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto p-4">
{/* Step 1: Course basics */}
{step === 1 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Course details</h2>
<div className="space-y-2">
<Label htmlFor="course-name">Course name</Label>
<Input
id="course-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Augusta National"
autoFocus
/>
</div>
<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={() => {
setHolesCount(n)
setHoles(defaultHoles(n))
setPar(n === 9 ? '36' : '72')
}}
className={`flex-1 rounded-lg border py-3 text-sm font-medium transition-colors ${
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="course-par">Total par</Label>
<Input
id="course-par"
type="number"
value={par}
onChange={(e) => setPar(e.target.value)}
min={27}
max={90}
/>
</div>
<Button
className="w-full"
disabled={!name || !par}
onClick={() => setStep(2)}
>
Next: Add tees
</Button>
</div>
)}
{/* Step 2: Tees */}
{step === 2 && (
<div className="space-y-4">
<h2 className="text-lg font-semibold">Tees</h2>
{/* Existing tees */}
{tees.length > 0 && (
<div className="space-y-2">
{tees.map((t, i) => (
<div key={i} className="flex items-center justify-between rounded-lg border p-3">
<div className="flex items-center gap-3">
<div
className="h-4 w-4 rounded-full border"
style={{ backgroundColor: t.color }}
/>
<div>
<div className="font-medium">{t.name}</div>
<div className="text-xs text-muted-foreground">
Rating {t.course_rating} · Slope {t.slope_rating}
</div>
</div>
</div>
<button
onClick={() => removeTee(i)}
className="text-xs text-muted-foreground hover:text-destructive"
>
Remove
</button>
</div>
))}
</div>
)}
{/* Add tee form */}
<div className="space-y-3 rounded-lg border p-3">
<p className="text-sm font-medium">Add tee</p>
<div className="space-y-2">
<Label>Color</Label>
<div className="flex flex-wrap gap-2">
{TEE_COLORS.map((c) => (
<button
key={c.value}
title={c.label}
onClick={() => setTeeForm({ ...teeForm, color: c.value })}
className={`h-7 w-7 rounded-full border-2 transition-transform ${
teeForm.color === c.value
? 'scale-110 border-foreground'
: 'border-transparent'
}`}
style={{ backgroundColor: c.value }}
/>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label htmlFor="tee-name">Name</Label>
<Input
id="tee-name"
value={teeForm.name}
onChange={(e) => setTeeForm({ ...teeForm, name: e.target.value })}
placeholder="e.g. White"
/>
</div>
<div className="space-y-1">
<Label htmlFor="tee-rating">Course rating</Label>
<Input
id="tee-rating"
type="number"
value={teeForm.course_rating}
onChange={(e) => setTeeForm({ ...teeForm, course_rating: e.target.value })}
placeholder="71.5"
step="0.1"
/>
</div>
<div className="space-y-1">
<Label htmlFor="tee-slope">Slope</Label>
<Input
id="tee-slope"
type="number"
value={teeForm.slope_rating}
onChange={(e) => setTeeForm({ ...teeForm, slope_rating: e.target.value })}
placeholder="125"
/>
</div>
</div>
<Button
variant="outline"
className="w-full"
onClick={addTee}
disabled={!teeForm.name || !teeForm.course_rating || !teeForm.slope_rating}
>
Add tee
</Button>
</div>
<Button
className="w-full"
disabled={tees.length === 0}
onClick={() => setStep(3)}
>
Next: Add holes
</Button>
</div>
)}
{/* Step 3: Holes */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Holes</h2>
<span className={`text-sm font-medium ${parValid ? 'text-green-600' : 'text-destructive'}`}>
Par sum: {parSum} / {parTarget}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 font-medium">Hole</th>
<th className="pb-2 font-medium">Par</th>
<th className="pb-2 font-medium">SI</th>
</tr>
</thead>
<tbody className="divide-y">
{holes.slice(0, holesCount).map((hole, i) => (
<tr key={hole.hole_number}>
<td className="py-2 pr-4 font-medium">{hole.hole_number}</td>
<td className="py-1 pr-4">
<div className="flex gap-1">
{[3, 4, 5].map((p) => (
<button
key={p}
onClick={() => updateHole(i, 'par', p)}
className={`h-8 w-8 rounded text-xs font-medium transition-colors ${
hole.par === p
? 'bg-primary text-primary-foreground'
: 'border border-border hover:bg-muted'
}`}
>
{p}
</button>
))}
</div>
</td>
<td className="py-1">
<input
type="number"
value={hole.stroke_index}
onChange={(e) => updateHole(i, 'stroke_index', parseInt(e.target.value) || 1)}
min={1}
max={holesCount}
className="h-8 w-14 rounded border border-border bg-background px-2 text-sm"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<Button
className="w-full"
disabled={!parValid || loading}
onClick={handleSubmit}
>
{loading ? 'Creating…' : 'Create course'}
</Button>
</div>
)}
</div>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { createClient } from '@/lib/supabase/server'
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { buttonVariants } from '@/components/ui/button'
import { cn } from '@/lib/utils'
export default async function DashboardPage() {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
type RoundRow = {
round: {
id: string
name: string | null
date: string
status: string
holes_count: number
course: { name: string } | null
tee: { name: string; color: string } | null
} | null
}
// Fetch rounds the user is participating in
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: rounds } = (await (supabase as any)
.from('round_players')
.select(
`
round:rounds (
id, name, date, status, holes_count,
course:courses (name),
tee:tees (name, color)
)
`,
)
.eq('user_id', user.id)
.in('round.status', ['lobby', 'active'])
.order('round(date)', { ascending: false })) as { data: RoundRow[] | null }
return (
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Rounds</h1>
<Link href="/rounds/new" className={cn(buttonVariants({ size: 'sm' }))}>
New round
</Link>
</div>
{!rounds?.length && (
<p className="text-muted-foreground text-sm py-8 text-center">
No active rounds. Start one!
</p>
)}
<div className="space-y-3">
{rounds?.map(({ round }) => {
if (!round) return null
return (
<Link
key={round.id}
href={round.status === 'active' ? `/rounds/${round.id}/scorecard` : `/rounds/${round.id}`}
className="block rounded-xl border p-4 space-y-1 hover:bg-muted transition-colors"
>
<div className="flex items-center justify-between">
<span className="font-semibold">{round.course?.name}</span>
<StatusBadge status={round.status} />
</div>
<div className="text-sm text-muted-foreground">
{round.name && <span>{round.name} · </span>}
{round.tee?.name} tees · {round.holes_count} holes ·{' '}
{new Date(round.date).toLocaleDateString()}
</div>
</Link>
)
})}
</div>
</div>
)
}
function StatusBadge({ status }: { status: string }) {
const styles = {
lobby: 'bg-yellow-100 text-yellow-800',
active: 'bg-green-100 text-green-800',
completed: 'bg-gray-100 text-gray-600',
}
return (
<span
className={`text-xs font-medium px-2 py-0.5 rounded-full ${styles[status as keyof typeof styles] ?? styles.completed}`}
>
{status}
</span>
)
}
+23
View File
@@ -0,0 +1,23 @@
import { redirect } from 'next/navigation'
import { createClient } from '@/lib/supabase/server'
import { BottomNav } from '@/components/bottom-nav'
export default async function AppLayout({
children,
}: {
children: React.ReactNode
}) {
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
return (
<div className="flex min-h-screen flex-col">
<main className="flex-1 pb-20">{children}</main>
<BottomNav />
</div>
)
}
+43
View File
@@ -0,0 +1,43 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { JoinRound } from '@/components/round/join-round'
export default async function JoinRoundPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect(`/login`)
const { data: round } = await db
.from('rounds')
.select(
`
id, name, date, holes_count, status, created_by,
course:courses (name),
tee:tees (name, color),
round_players (user_id)
`,
)
.eq('id', id)
.single()
if (!round) notFound()
// Already a member — go straight to the round
const alreadyJoined = round.round_players?.some(
(p: { user_id: string }) => p.user_id === user.id,
)
if (alreadyJoined) redirect(`/rounds/${id}`)
// Round must be in lobby to join
if (round.status !== 'lobby') {
redirect(`/dashboard`)
}
return <JoinRound round={round} userId={user.id} />
}
+63
View File
@@ -0,0 +1,63 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { RoundLobby } from '@/components/round/round-lobby'
export default async function RoundPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: round } = await db
.from('rounds')
.select(
`
id, name, date, status, holes_count, created_by,
course:courses (id, name, par),
tee:tees (id, name, color, course_rating, slope_rating)
`,
)
.eq('id', id)
.single()
if (!round) notFound()
const { data: players } = await db
.from('round_players')
.select(
`
id, role, handicap_index, course_handicap, joined_at,
profile:profiles (id, display_name, avatar_url)
`,
)
.eq('round_id', id)
.order('joined_at')
const { data: invites } = await db
.from('round_invites')
.select('id, email, accepted_at, created_at')
.eq('round_id', id)
.is('accepted_at', null)
.order('created_at')
const isCreator = round.created_by === user.id
if (round.status === 'active' || round.status === 'completed') {
redirect(`/rounds/${id}/scorecard`)
}
return (
<RoundLobby
round={round}
players={players ?? []}
pendingInvites={invites ?? []}
currentUserId={user.id}
isCreator={isCreator}
/>
)
}
+104
View File
@@ -0,0 +1,104 @@
import { createClient } from '@/lib/supabase/server'
import { redirect, notFound } from 'next/navigation'
import { ScorecardView } from '@/components/scorecard/scorecard-view'
export type Hole = {
id: string
hole_number: number
par: number
stroke_index: number
}
export type Player = {
user_id: string
course_handicap: number | null
handicap_index: number | null
profile: { id: string; display_name: string; avatar_url: string | null } | null
}
export type ScoreRow = {
hole_id: string
player_id: string
strokes: number
}
export type RoundInfo = {
id: string
name: string | null
date: string
status: string
holes_count: number
created_by: string
course: { name: string } | null
tee: { name: string; color: string } | null
}
export default async function ScorecardPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) redirect('/login')
const { data: round } = await db
.from('rounds')
.select(
`id, name, date, status, holes_count, created_by,
course:courses (name),
tee:tees (name, color)`,
)
.eq('id', id)
.single()
if (!round) notFound()
if (round.status === 'lobby') redirect(`/rounds/${id}`)
// Holes ordered by hole_number, limited to holes_count
const { data: holes } = await db
.from('holes')
.select('id, hole_number, par, stroke_index')
.eq('course_id', await getCourseId(db, id))
.order('hole_number')
.limit(round.holes_count)
const { data: players } = await db
.from('round_players')
.select(
`user_id, course_handicap, handicap_index,
profile:profiles (id, display_name, avatar_url)`,
)
.eq('round_id', id)
.order('joined_at')
const { data: scores } = await db
.from('scores')
.select('hole_id, player_id, strokes')
.eq('round_id', id)
return (
<ScorecardView
round={round as RoundInfo}
holes={(holes as Hole[]) ?? []}
players={(players as Player[]) ?? []}
initialScores={(scores as ScoreRow[]) ?? []}
currentUserId={user.id}
/>
)
}
async function getCourseId(db: any, roundId: string): Promise<string> {
const { data } = await db
.from('rounds')
.select('course_id')
.eq('id', roundId)
.single()
return data?.course_id ?? ''
}
+28
View File
@@ -0,0 +1,28 @@
import { createClient } from '@/lib/supabase/server'
import { NewRoundWizard } from '@/components/round/new-round-wizard'
type Tee = {
id: string
name: string
color: string
course_rating: number
slope_rating: number
}
type Course = {
id: string
name: string
par: number
tees: Tee[]
}
export default async function NewRoundPage() {
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data: courses } = await (supabase as any)
.from('courses')
.select('id, name, par, tees(id, name, color, course_rating, slope_rating)')
.order('name')
return <NewRoundWizard courses={(courses as Course[]) ?? []} />
}
+19
View File
@@ -0,0 +1,19 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse, type NextRequest } from 'next/server'
export async function GET(request: NextRequest) {
const { searchParams, origin } = new URL(request.url)
const code = searchParams.get('code')
const next = searchParams.get('next') ?? '/dashboard'
if (code) {
const supabase = await createClient()
const { error } = await supabase.auth.exchangeCodeForSession(code)
if (!error) {
return NextResponse.redirect(`${origin}${next}`)
}
}
return NextResponse.redirect(`${origin}/login?error=auth_failed`)
}
+85
View File
@@ -0,0 +1,85 @@
'use client'
import { useState } from 'react'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export default function LoginPage() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError(null)
const supabase = createClient()
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: `${window.location.origin}/auth/confirm`,
shouldCreateUser: false, // invite-only: only existing users can log in
},
})
if (error) {
setError(error.message)
} else {
setSubmitted(true)
}
setLoading(false)
}
if (submitted) {
return (
<div className="flex min-h-screen items-center justify-center p-6">
<div className="w-full max-w-sm space-y-4 text-center">
<div className="text-4xl"></div>
<h1 className="text-2xl font-bold">Check your email</h1>
<p className="text-muted-foreground">
We sent a magic link to <strong>{email}</strong>. Tap the link to
sign in.
</p>
</div>
</div>
)
}
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-2 text-center">
<div className="text-4xl"></div>
<h1 className="text-2xl font-bold">MulliganMates</h1>
<p className="text-muted-foreground">Sign in to track your round</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="you@example.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
autoComplete="email"
autoFocus
/>
</div>
{error && <p className="text-destructive text-sm">{error}</p>}
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Sending…' : 'Send magic link'}
</Button>
</form>
</div>
</div>
)
}
+62
View File
@@ -0,0 +1,62 @@
'use server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
const TeeInput = z.object({
name: z.string().min(1, 'Tee name required'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/),
course_rating: z.coerce.number().min(50).max(80),
slope_rating: z.coerce.number().int().min(55).max(155),
})
const HoleInput = z.object({
hole_number: z.number().int().min(1).max(18),
par: z.number().int().min(3).max(5),
stroke_index: z.number().int().min(1).max(18),
})
export const CreateCourseSchema = z.object({
name: z.string().min(1, 'Course name required').max(100),
par: z.number().int().min(27).max(90),
tees: z.array(TeeInput).min(1, 'At least one tee required'),
holes: z.array(HoleInput).min(9),
})
export type CreateCourseInput = z.infer<typeof CreateCourseSchema>
export async function createCourse(data: CreateCourseInput) {
const parsed = CreateCourseSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
}
const supabase = await createClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: course, error: courseError } = await db
.from('courses')
.insert({ name: parsed.data.name, par: parsed.data.par, created_by: user.id })
.select('id')
.single()
if (courseError) return { error: courseError.message }
const { error: teesError } = await db
.from('tees')
.insert(parsed.data.tees.map((t) => ({ ...t, course_id: course.id })))
if (teesError) return { error: teesError.message }
const { error: holesError } = await db
.from('holes')
.insert(parsed.data.holes.map((h) => ({ ...h, course_id: course.id })))
if (holesError) return { error: holesError.message }
revalidatePath('/rounds/new')
return { courseId: course.id as string }
}
+50
View File
@@ -0,0 +1,50 @@
'use server'
import { z } from 'zod'
import { createClient, createAdminClient } from '@/lib/supabase/server'
const InviteSchema = z.object({
roundId: z.string().uuid(),
email: z.string().email(),
})
export async function inviteFromLobby(roundId: string, email: string) {
const parsed = InviteSchema.safeParse({ roundId, email })
if (!parsed.success) return { error: 'Invalid email address' }
const supabase = await createClient()
const adminClient = await createAdminClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { data: round } = await db
.from('rounds')
.select('status, created_by')
.eq('id', parsed.data.roundId)
.single()
if (!round) return { error: 'Round not found' }
if (round.created_by !== user.id) return { error: 'Only the round creator can invite players' }
if (round.status !== 'lobby') return { error: 'Cannot invite after the round has started' }
const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${parsed.data.roundId}/join`)}`
const { error: inviteError } = await (adminClient as any).auth.admin.inviteUserByEmail(
parsed.data.email,
{ redirectTo },
)
if (inviteError) return { error: inviteError.message }
await db.from('round_invites').insert({
round_id: parsed.data.roundId,
email: parsed.data.email,
invited_by: user.id,
})
return { success: true }
}
+129
View File
@@ -0,0 +1,129 @@
'use server'
import { z } from 'zod'
import { createClient, createAdminClient } from '@/lib/supabase/server'
import { revalidatePath } from 'next/cache'
const CreateRoundSchema = z.object({
course_id: z.string().uuid(),
tee_id: z.string().uuid(),
name: z.string().max(100).optional(),
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
holes_count: z.union([z.literal(9), z.literal(18)]),
emails: z.array(z.string().email()).default([]),
})
export type CreateRoundInput = z.infer<typeof CreateRoundSchema>
export async function createRoundWithInvites(data: CreateRoundInput) {
const parsed = CreateRoundSchema.safeParse(data)
if (!parsed.success) {
return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
}
const supabase = await createClient()
const adminClient = await createAdminClient()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const db = supabase as any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const adminDb = adminClient as any
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
// Snapshot handicap and compute course handicap
const { data: profile } = await db
.from('profiles')
.select('handicap_index')
.eq('id', user.id)
.single()
const { data: tee } = await db
.from('tees')
.select('course_rating, slope_rating')
.eq('id', parsed.data.tee_id)
.single()
const { data: course } = await db
.from('courses')
.select('par')
.eq('id', parsed.data.course_id)
.single()
const handicapIndex: number | null = profile?.handicap_index ?? null
const courseHandicap =
handicapIndex !== null && tee && course
? Math.round(handicapIndex * (tee.slope_rating / 113) + (tee.course_rating - course.par))
: null
// Create round
const { data: round, error: roundError } = await db
.from('rounds')
.insert({
course_id: parsed.data.course_id,
tee_id: parsed.data.tee_id,
name: parsed.data.name || null,
date: parsed.data.date,
holes_count: parsed.data.holes_count,
created_by: user.id,
status: 'lobby',
})
.select('id')
.single()
if (roundError) return { error: roundError.message }
// Add creator as admin player
await db.from('round_players').insert({
round_id: round.id,
user_id: user.id,
role: 'admin',
handicap_index: handicapIndex,
course_handicap: courseHandicap,
})
// Send invites
const failedInvites: string[] = []
for (const email of parsed.data.emails) {
const redirectTo = `${process.env.SITE_URL}/auth/confirm?next=${encodeURIComponent(`/rounds/${round.id}/join`)}`
const { error } = await adminDb.auth.admin.inviteUserByEmail(email, { redirectTo })
if (error) {
failedInvites.push(email)
} else {
await db.from('round_invites').insert({
round_id: round.id,
email,
invited_by: user.id,
})
}
}
return {
roundId: round.id as string,
failedInvites,
}
}
export async function startRound(roundId: string) {
const parsed = z.string().uuid().safeParse(roundId)
if (!parsed.success) return { error: 'Invalid round ID' }
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
const { error } = await (supabase as any)
.from('rounds')
.update({ status: 'active' })
.eq('id', parsed.data)
.eq('created_by', user.id)
.eq('status', 'lobby')
if (error) return { error: error.message }
revalidatePath(`/rounds/${roundId}`)
return { success: true }
}
+45
View File
@@ -0,0 +1,45 @@
'use server'
import { z } from 'zod'
import { createClient } from '@/lib/supabase/server'
const UpsertScoreSchema = z.object({
roundId: z.string().uuid(),
holeId: z.string().uuid(),
playerId: z.string().uuid(),
strokes: z.number().int().min(1).max(20),
})
export async function upsertScore(
roundId: string,
holeId: string,
playerId: string,
strokes: number,
) {
const parsed = UpsertScoreSchema.safeParse({ roundId, holeId, playerId, strokes })
if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? 'Invalid data' }
const supabase = await createClient()
const {
data: { user },
} = await supabase.auth.getUser()
if (!user) return { error: 'Not authenticated' }
// Verify round is active and user is a member (RLS also enforces this)
const { error } = await (supabase as any)
.from('scores')
.upsert(
{
round_id: parsed.data.roundId,
hole_id: parsed.data.holeId,
player_id: parsed.data.playerId,
strokes: parsed.data.strokes,
updated_by: user.id,
updated_at: new Date().toISOString(),
},
{ onConflict: 'round_id,hole_id,player_id' },
)
if (error) return { error: error.message }
return { success: true }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+129
View File
@@ -0,0 +1,129 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.809 0.105 251.813);
--chart-2: oklch(0.623 0.214 259.815);
--chart-3: oklch(0.546 0.245 262.881);
--chart-4: oklch(0.488 0.243 264.376);
--chart-5: oklch(0.424 0.199 265.638);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}
+32
View File
@@ -0,0 +1,32 @@
import type { MetadataRoute } from 'next'
export default function manifest(): MetadataRoute.Manifest {
return {
name: 'MulliganMates',
short_name: 'MulliganMates',
description: 'Social golf score tracking',
start_url: '/dashboard',
display: 'standalone',
background_color: '#ffffff',
theme_color: '#16a34a',
orientation: 'portrait',
icons: [
{
src: '/icons/icon-192.png',
sizes: '192x192',
type: 'image/png',
},
{
src: '/icons/icon-512.png',
sizes: '512x512',
type: 'image/png',
},
{
src: '/icons/icon-512-maskable.png',
sizes: '512x512',
type: 'image/png',
purpose: 'maskable',
},
],
}
}
+65
View File
@@ -0,0 +1,65 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}