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