Initial commit v1
This commit is contained in:
@@ -0,0 +1,92 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## What This Is
|
||||||
|
|
||||||
|
MulliganMates is a social golf score tracking PWA. Multiple players in a group track scores per hole in real-time. Anyone in a group can enter or edit scores for any player. Built as a self-hosted Next.js + Supabase app running in Docker Compose.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
All commands run inside Docker (Node.js is not installed on the host):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Development (with Studio + Mailpit)
|
||||||
|
docker compose --profile dev up
|
||||||
|
|
||||||
|
# Production
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# Install a new npm package
|
||||||
|
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm install <package>
|
||||||
|
|
||||||
|
# Add a shadcn/ui component
|
||||||
|
docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add <component>
|
||||||
|
|
||||||
|
# Type-check
|
||||||
|
docker run --rm -v $(pwd):/app -w /app node:22-alpine npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
**Stack:** Next.js 15 (App Router) · TypeScript · Tailwind CSS v4 · shadcn/ui · Supabase (self-hosted) · TanStack Query v5 · Zustand · Serwist (PWA)
|
||||||
|
|
||||||
|
**Routing groups:**
|
||||||
|
- `app/(auth)/` — unauthenticated pages (login, confirm)
|
||||||
|
- `app/(app)/` — authenticated app shell with bottom navigation
|
||||||
|
|
||||||
|
**Supabase clients:**
|
||||||
|
- `lib/supabase/client.ts` — browser client (use in `'use client'` components)
|
||||||
|
- `lib/supabase/server.ts` — server client + admin client (Server Components, Server Actions, Route Handlers)
|
||||||
|
- Always use `supabase.auth.getUser()` server-side, never `getSession()` — getUser() cryptographically verifies the JWT
|
||||||
|
|
||||||
|
**Auth flow:**
|
||||||
|
- Invite-only. New users are invited via `supabase.auth.admin.inviteUserByEmail()` (uses service role key).
|
||||||
|
- Magic link → `/auth/confirm` route handler exchanges the PKCE code for a session.
|
||||||
|
- `middleware.ts` guards all `/(app)` routes; redirects unauthenticated users to `/login`.
|
||||||
|
- Admin email is set via `ADMIN_EMAIL` env var; `lib/admin.ts` exposes `isAdminEmail()`.
|
||||||
|
|
||||||
|
**Real-time scores:**
|
||||||
|
- Supabase Postgres Changes subscriptions on the `scores` table, filtered by `round_id`.
|
||||||
|
- TanStack Query cache is invalidated on change events.
|
||||||
|
- Score entry uses UPSERT — `unique(round_id, hole_id, player_id)` constraint.
|
||||||
|
|
||||||
|
**Round lifecycle:** `lobby → active → completed`
|
||||||
|
- Lobby: players can be invited and join; no scoring yet.
|
||||||
|
- Active: started by round creator; scoring enabled; no new players.
|
||||||
|
- Completed: closed by round creator; read-only.
|
||||||
|
|
||||||
|
## Database
|
||||||
|
|
||||||
|
Migrations live in `supabase/migrations/` and are applied on DB container startup via `/docker-entrypoint-initdb.d/`.
|
||||||
|
|
||||||
|
- `00001_schema.sql` — tables, triggers (auto-create profile, enforce course par sum, update scores.updated_at)
|
||||||
|
- `00002_rls.sql` — Row Level Security policies for all tables
|
||||||
|
|
||||||
|
**Key RLS rules:**
|
||||||
|
- Scores can be inserted/updated by any round member, but only when `round.status = 'active'`
|
||||||
|
- `is_round_member(round_id)` helper function used throughout RLS policies
|
||||||
|
- `service_role` key bypasses RLS — only use server-side, never expose to client
|
||||||
|
|
||||||
|
**Course par validation:** A DB trigger fires on `holes` INSERT/UPDATE; once 9 or 18 holes exist for a course, it asserts `SUM(holes.par) = courses.par`.
|
||||||
|
|
||||||
|
**Handicap calculation (WHS):**
|
||||||
|
`course_handicap = handicap_index × (slope_rating ÷ 113) + (course_rating − par)`
|
||||||
|
Stored as a snapshot on `round_players` at join time.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- **Docker Compose** — all services defined in `docker-compose.yml`
|
||||||
|
- **Traefik** — external reverse proxy; services expose themselves via labels only (no port mappings)
|
||||||
|
- `studio` and `mailpit` services are in the `dev` profile — not started in production
|
||||||
|
- **Email dev:** Mailpit catches all outgoing email (`SMTP_HOST=mailpit`)
|
||||||
|
- **Email prod:** Set `SMTP_HOST`, `SMTP_USER`, `SMTP_PASS` to Resend/Mailgun/etc. in `.env`
|
||||||
|
- Copy `.env.example` → `.env` and fill all values before first run
|
||||||
|
|
||||||
|
## Key Conventions
|
||||||
|
|
||||||
|
- Net score vs par (WHS handicap-adjusted) is always the **primary** display, in bold
|
||||||
|
- Raw strokes vs course par is secondary
|
||||||
|
- Score entry defaults to hole par; +/− buttons; tap number for picker
|
||||||
|
- Bottom navigation: Rounds · History · Profile (+ Admin tab for admin user only)
|
||||||
|
- Mobile-first: minimum 44×44px tap targets, swipe between holes, high-contrast for sunlight
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
FROM node:22-alpine AS base
|
||||||
|
|
||||||
|
# ── Dependencies ──────────────────────────────────────────────
|
||||||
|
FROM base AS deps
|
||||||
|
RUN apk add --no-cache libc6-compat
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ── Builder ───────────────────────────────────────────────────
|
||||||
|
FROM base AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
# Disable Next.js telemetry
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ── Runner ────────────────────────────────────────────────────
|
||||||
|
FROM base AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
|
RUN addgroup --system --gid 1001 nodejs
|
||||||
|
RUN adduser --system --uid 1001 nextjs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME="0.0.0.0"
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
First, run the development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
# or
|
||||||
|
yarn dev
|
||||||
|
# or
|
||||||
|
pnpm dev
|
||||||
|
# or
|
||||||
|
bun dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||||
|
|
||||||
|
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||||
|
|
||||||
|
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||||
|
|
||||||
|
## Learn More
|
||||||
|
|
||||||
|
To learn more about Next.js, take a look at the following resources:
|
||||||
|
|
||||||
|
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||||
|
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||||
|
|
||||||
|
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||||
|
|
||||||
|
## Deploy on Vercel
|
||||||
|
|
||||||
|
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||||
|
|
||||||
|
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 ?? ''
|
||||||
|
}
|
||||||
@@ -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[]) ?? []} />
|
||||||
|
}
|
||||||
@@ -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`)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
+129
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://ui.shadcn.com/schema.json",
|
||||||
|
"style": "base-nova",
|
||||||
|
"rsc": true,
|
||||||
|
"tsx": true,
|
||||||
|
"tailwind": {
|
||||||
|
"config": "",
|
||||||
|
"css": "app/globals.css",
|
||||||
|
"baseColor": "neutral",
|
||||||
|
"cssVariables": true,
|
||||||
|
"prefix": ""
|
||||||
|
},
|
||||||
|
"iconLibrary": "lucide",
|
||||||
|
"rtl": false,
|
||||||
|
"aliases": {
|
||||||
|
"components": "@/components",
|
||||||
|
"utils": "@/lib/utils",
|
||||||
|
"ui": "@/components/ui",
|
||||||
|
"lib": "@/lib",
|
||||||
|
"hooks": "@/hooks"
|
||||||
|
},
|
||||||
|
"menuColor": "default",
|
||||||
|
"menuAccent": "subtle",
|
||||||
|
"registries": {}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
name: mulliganmates
|
||||||
|
|
||||||
|
x-supabase-env: &supabase-env
|
||||||
|
POSTGRES_HOST: ${POSTGRES_HOST:-db}
|
||||||
|
POSTGRES_PORT: ${POSTGRES_PORT:-5432}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
ANON_KEY: ${ANON_KEY}
|
||||||
|
SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
# ── Application ────────────────────────────────────────────
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
ADMIN_EMAIL: ${ADMIN_EMAIL}
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.mulliganmates.rule=Host(`${APP_DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.mulliganmates.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mulliganmates.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mulliganmates.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
auth:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# ── Supabase: Database ─────────────────────────────────────
|
||||||
|
db:
|
||||||
|
image: supabase/postgres:15.8.1.085
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "pg_isready", "-U", "postgres", "-d", "postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
environment:
|
||||||
|
<<: *supabase-env
|
||||||
|
POSTGRES_HOST: /var/run/postgresql
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/postgresql/data
|
||||||
|
- ./supabase/migrations:/docker-entrypoint-initdb.d:ro
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
|
||||||
|
# ── Supabase: Kong (API Gateway) ───────────────────────────
|
||||||
|
kong:
|
||||||
|
image: kong/kong:3.9.1
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
KONG_DATABASE: "off"
|
||||||
|
KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml
|
||||||
|
KONG_DNS_ORDER: LAST,A,CNAME
|
||||||
|
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
|
||||||
|
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
|
||||||
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
volumes:
|
||||||
|
- ./volumes/api/kong.yml:/etc/kong/kong.yml:ro
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.supabase-api.rule=Host(`${SUPABASE_DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.supabase-api.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.supabase-api.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.supabase-api.loadbalancer.server.port=8000"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Supabase: Auth (GoTrue) ────────────────────────────────
|
||||||
|
auth:
|
||||||
|
image: supabase/gotrue:v2.186.0
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *supabase-env
|
||||||
|
GOTRUE_API_HOST: "0.0.0.0"
|
||||||
|
GOTRUE_API_PORT: 9999
|
||||||
|
API_EXTERNAL_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
GOTRUE_DB_DRIVER: postgres
|
||||||
|
GOTRUE_DB_DATABASE_URL: "postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
||||||
|
GOTRUE_SITE_URL: ${SITE_URL}
|
||||||
|
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS:-}
|
||||||
|
GOTRUE_DISABLE_SIGNUP: "true"
|
||||||
|
GOTRUE_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
GOTRUE_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||||
|
GOTRUE_MAILER_AUTOCONFIRM: "false"
|
||||||
|
GOTRUE_SMTP_HOST: ${SMTP_HOST}
|
||||||
|
GOTRUE_SMTP_PORT: ${SMTP_PORT:-1025}
|
||||||
|
GOTRUE_SMTP_USER: ${SMTP_USER:-}
|
||||||
|
GOTRUE_SMTP_PASS: ${SMTP_PASS:-}
|
||||||
|
GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME:-MulliganMates}
|
||||||
|
GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify"
|
||||||
|
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Supabase: REST (PostgREST) ─────────────────────────────
|
||||||
|
rest:
|
||||||
|
image: postgrest/postgrest:v14.6
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PGRST_DB_URI: "postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
||||||
|
PGRST_DB_SCHEMAS: "public,storage,graphql_public"
|
||||||
|
PGRST_DB_ANON_ROLE: anon
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PGRST_DB_USE_LEGACY_GUCS: "false"
|
||||||
|
PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY:-3600}
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Supabase: Realtime ─────────────────────────────────────
|
||||||
|
realtime:
|
||||||
|
image: supabase/realtime:v2.76.5
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PORT: 4000
|
||||||
|
DB_HOST: ${POSTGRES_HOST:-db}
|
||||||
|
DB_PORT: ${POSTGRES_PORT:-5432}
|
||||||
|
DB_USER: supabase_admin
|
||||||
|
DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
DB_NAME: ${POSTGRES_DB:-postgres}
|
||||||
|
DB_AFTER_CONNECT_QUERY: "SET search_path TO _realtime"
|
||||||
|
DB_ENC_KEY: ${SECRET_KEY_BASE}
|
||||||
|
API_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
FLY_ALLOC_ID: fly123
|
||||||
|
FLY_APP_NAME: realtime
|
||||||
|
SECRET_KEY_BASE: ${SECRET_KEY_BASE}
|
||||||
|
ERL_AFLAGS: -proto_dist inet_tcp
|
||||||
|
DNS_NODES: "''"
|
||||||
|
RLIMIT_NOFILE: "10000"
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Supabase: Storage ──────────────────────────────────────
|
||||||
|
storage:
|
||||||
|
image: supabase/storage-api:v1.44.2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
<<: *supabase-env
|
||||||
|
ANON_KEY: ${ANON_KEY}
|
||||||
|
SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
POSTGREST_URL: http://rest:3000
|
||||||
|
PGRST_JWT_SECRET: ${JWT_SECRET}
|
||||||
|
DATABASE_URL: "postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST:-db}:${POSTGRES_PORT:-5432}/${POSTGRES_DB:-postgres}"
|
||||||
|
FILE_SIZE_LIMIT: 52428800
|
||||||
|
STORAGE_BACKEND: file
|
||||||
|
FILE_STORAGE_BACKEND_PATH: /var/lib/storage
|
||||||
|
TENANT_ID: stub
|
||||||
|
REGION: stub
|
||||||
|
GLOBAL_S3_BUCKET: stub
|
||||||
|
volumes:
|
||||||
|
- storage_data:/var/lib/storage
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
rest:
|
||||||
|
condition: service_started
|
||||||
|
|
||||||
|
# ── Supabase: Meta ─────────────────────────────────────────
|
||||||
|
meta:
|
||||||
|
image: supabase/postgres-meta:v0.95.2
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
PG_META_PORT: 8080
|
||||||
|
PG_META_DB_HOST: ${POSTGRES_HOST:-db}
|
||||||
|
PG_META_DB_PORT: ${POSTGRES_PORT:-5432}
|
||||||
|
PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
|
||||||
|
PG_META_DB_USER: supabase_admin
|
||||||
|
PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
# ── Supabase: Studio (dev only) ────────────────────────────
|
||||||
|
studio:
|
||||||
|
image: supabase/studio:2026.03.16-sha-5528817
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["dev"]
|
||||||
|
environment:
|
||||||
|
STUDIO_PG_META_URL: http://meta:8080
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
DEFAULT_ORGANIZATION_NAME: MulliganMates
|
||||||
|
DEFAULT_PROJECT_NAME: MulliganMates
|
||||||
|
SUPABASE_URL: http://kong:8000
|
||||||
|
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
|
||||||
|
SUPABASE_ANON_KEY: ${ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
|
||||||
|
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN:-}
|
||||||
|
NEXT_PUBLIC_ENABLE_LOGS: "false"
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.supabase-studio.rule=Host(`${STUDIO_DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.supabase-studio.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.supabase-studio.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.supabase-studio.loadbalancer.server.port=3000"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
|
||||||
|
# ── Email: Mailpit (dev only) ──────────────────────────────
|
||||||
|
mailpit:
|
||||||
|
image: axllent/mailpit:latest
|
||||||
|
restart: unless-stopped
|
||||||
|
profiles: ["dev"]
|
||||||
|
labels:
|
||||||
|
- "traefik.enable=true"
|
||||||
|
- "traefik.http.routers.mailpit.rule=Host(`${MAILPIT_DOMAIN}`)"
|
||||||
|
- "traefik.http.routers.mailpit.entrypoints=websecure"
|
||||||
|
- "traefik.http.routers.mailpit.tls.certresolver=letsencrypt"
|
||||||
|
- "traefik.http.services.mailpit.loadbalancer.server.port=8025"
|
||||||
|
networks:
|
||||||
|
- traefik
|
||||||
|
- internal
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
storage_data:
|
||||||
|
|
||||||
|
networks:
|
||||||
|
traefik:
|
||||||
|
external: true
|
||||||
|
internal:
|
||||||
|
driver: bridge
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig, globalIgnores } from "eslint/config";
|
||||||
|
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import nextTs from "eslint-config-next/typescript";
|
||||||
|
|
||||||
|
const eslintConfig = defineConfig([
|
||||||
|
...nextVitals,
|
||||||
|
...nextTs,
|
||||||
|
// Override default ignores of eslint-config-next.
|
||||||
|
globalIgnores([
|
||||||
|
// Default ignores of eslint-config-next:
|
||||||
|
".next/**",
|
||||||
|
"out/**",
|
||||||
|
"build/**",
|
||||||
|
"next-env.d.ts",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
export default eslintConfig;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Returns true if the given email matches the configured admin email.
|
||||||
|
* The admin email is set via ADMIN_EMAIL environment variable.
|
||||||
|
*/
|
||||||
|
export function isAdminEmail(email: string | undefined): boolean {
|
||||||
|
if (!email) return false
|
||||||
|
return email.toLowerCase() === process.env.ADMIN_EMAIL?.toLowerCase()
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Returns the number of handicap strokes a player receives on a given hole.
|
||||||
|
* WHS allocation: distribute course_handicap strokes across holes by stroke index.
|
||||||
|
*/
|
||||||
|
export function strokesReceived(
|
||||||
|
courseHandicap: number,
|
||||||
|
strokeIndex: number,
|
||||||
|
holesCount: number,
|
||||||
|
): number {
|
||||||
|
if (courseHandicap <= 0) return 0
|
||||||
|
const fullStrokes = Math.floor(courseHandicap / holesCount)
|
||||||
|
const extraStrokes = courseHandicap % holesCount
|
||||||
|
return fullStrokes + (strokeIndex <= extraStrokes ? 1 : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Net score on a hole (gross strokes minus strokes received).
|
||||||
|
*/
|
||||||
|
export function netStrokes(
|
||||||
|
gross: number,
|
||||||
|
courseHandicap: number,
|
||||||
|
strokeIndex: number,
|
||||||
|
holesCount: number,
|
||||||
|
): number {
|
||||||
|
return gross - strokesReceived(courseHandicap, strokeIndex, holesCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Score vs par as a display string: "−2", "E", "+1", "+3"
|
||||||
|
*/
|
||||||
|
export function formatVsPar(diff: number): string {
|
||||||
|
if (diff === 0) return 'E'
|
||||||
|
return diff > 0 ? `+${diff}` : `${diff}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS class for a gross score vs par (standard golf scorecard conventions).
|
||||||
|
*/
|
||||||
|
export function scoreColorClass(vsPar: number | null): string {
|
||||||
|
if (vsPar === null) return ''
|
||||||
|
if (vsPar <= -2) return 'bg-yellow-300 text-yellow-900 rounded-full' // Eagle or better
|
||||||
|
if (vsPar === -1) return 'bg-red-500 text-white rounded-full' // Birdie
|
||||||
|
if (vsPar === 0) return '' // Par
|
||||||
|
if (vsPar === 1) return 'border border-border rounded-sm' // Bogey
|
||||||
|
if (vsPar === 2) return 'border-2 border-border rounded-sm' // Double
|
||||||
|
return 'border-2 border-destructive rounded-sm text-destructive' // Triple+
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { createBrowserClient } from '@supabase/ssr'
|
||||||
|
import type { Database } from './types'
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { cookies } from 'next/headers'
|
||||||
|
import type { Database } from './types'
|
||||||
|
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
return createServerClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options),
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
// Server Component — cookie setting ignored (middleware handles refresh)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createAdminClient() {
|
||||||
|
const cookieStore = await cookies()
|
||||||
|
|
||||||
|
return createServerClient<Database>(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options),
|
||||||
|
)
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Auto-generated types will go here after running: supabase gen types typescript
|
||||||
|
// For now, a placeholder that will be replaced once the DB schema is applied.
|
||||||
|
export type Database = {
|
||||||
|
public: {
|
||||||
|
Tables: Record<string, never>
|
||||||
|
Views: Record<string, never>
|
||||||
|
Functions: Record<string, never>
|
||||||
|
Enums: Record<string, never>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { createServerClient } from '@supabase/ssr'
|
||||||
|
import { NextResponse, type NextRequest } from 'next/server'
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = ['/login', '/auth/confirm']
|
||||||
|
|
||||||
|
export async function middleware(request: NextRequest) {
|
||||||
|
let supabaseResponse = NextResponse.next({ request })
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll()
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
|
request.cookies.set(name, value),
|
||||||
|
)
|
||||||
|
supabaseResponse = NextResponse.next({ request })
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
supabaseResponse.cookies.set(name, value, options),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Use getClaims() for cryptographic JWT verification (not getSession())
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser()
|
||||||
|
|
||||||
|
const { pathname } = request.nextUrl
|
||||||
|
|
||||||
|
const isPublicPath = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
|
||||||
|
|
||||||
|
if (!user && !isPublicPath) {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/login'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user && pathname === '/login') {
|
||||||
|
const url = request.nextUrl.clone()
|
||||||
|
url.pathname = '/dashboard'
|
||||||
|
return NextResponse.redirect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
return supabaseResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: [
|
||||||
|
'/((?!_next/static|_next/image|favicon.ico|icons|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)',
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { NextConfig } from 'next'
|
||||||
|
|
||||||
|
const nextConfig: NextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
|
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: '/(.*)',
|
||||||
|
headers: [
|
||||||
|
{ key: 'X-Content-Type-Options', value: 'nosniff' },
|
||||||
|
{ key: 'X-Frame-Options', value: 'DENY' },
|
||||||
|
{ key: 'X-XSS-Protection', value: '1; mode=block' },
|
||||||
|
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
|
||||||
|
{ key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=()' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
Generated
+10148
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "app",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start",
|
||||||
|
"lint": "eslint"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.3.0",
|
||||||
|
"@supabase/ssr": "^0.9.0",
|
||||||
|
"@supabase/supabase-js": "^2.99.2",
|
||||||
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@tanstack/react-query-devtools": "^5.91.3",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.577.0",
|
||||||
|
"next": "16.1.7",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"serwist": "^9.5.7",
|
||||||
|
"shadcn": "^4.0.8",
|
||||||
|
"tailwind-merge": "^3.5.0",
|
||||||
|
"tw-animate-css": "^1.4.0",
|
||||||
|
"zod": "^4.3.6",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4",
|
||||||
|
"@types/node": "^20.19.37",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"eslint": "^9",
|
||||||
|
"eslint-config-next": "16.1.7",
|
||||||
|
"prettier": "^3.8.1",
|
||||||
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
|
"tailwindcss": "^4",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 391 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||||
|
After Width: | Height: | Size: 128 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||||
|
After Width: | Height: | Size: 385 B |
@@ -0,0 +1,159 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- MulliganMates — Initial Schema
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
-- Profiles (extends auth.users)
|
||||||
|
create table public.profiles (
|
||||||
|
id uuid primary key references auth.users on delete cascade,
|
||||||
|
display_name text not null,
|
||||||
|
avatar_url text,
|
||||||
|
handicap_index numeric(4,1) check (handicap_index between -10 and 54),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Automatically create a profile when a new user is confirmed
|
||||||
|
create or replace function public.handle_new_user()
|
||||||
|
returns trigger language plpgsql security definer set search_path = public
|
||||||
|
as $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, display_name)
|
||||||
|
values (new.id, coalesce(new.raw_user_meta_data->>'display_name', split_part(new.email, '@', 1)));
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger on_auth_user_created
|
||||||
|
after insert on auth.users
|
||||||
|
for each row execute procedure public.handle_new_user();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Courses
|
||||||
|
-- ============================================================
|
||||||
|
create table public.courses (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
name text not null,
|
||||||
|
par int not null check (par between 27 and 90),
|
||||||
|
created_by uuid not null references public.profiles on delete set null,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Tees per course
|
||||||
|
create table public.tees (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
course_id uuid not null references public.courses on delete cascade,
|
||||||
|
name text not null, -- e.g. "Yellow", "White", "Blue"
|
||||||
|
color text not null default '#888888',
|
||||||
|
course_rating numeric(4,1) not null,
|
||||||
|
slope_rating int not null check (slope_rating between 55 and 155),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Holes (shared across tees on the same course)
|
||||||
|
create table public.holes (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
course_id uuid not null references public.courses on delete cascade,
|
||||||
|
hole_number int not null check (hole_number between 1 and 18),
|
||||||
|
par int not null check (par between 3 and 5),
|
||||||
|
stroke_index int not null check (stroke_index between 1 and 18),
|
||||||
|
unique (course_id, hole_number)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Validate that sum of hole pars equals the course par
|
||||||
|
create or replace function public.check_course_par()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
declare
|
||||||
|
total_par int;
|
||||||
|
course_par int;
|
||||||
|
hole_count int;
|
||||||
|
begin
|
||||||
|
select count(*), sum(h.par)
|
||||||
|
into hole_count, total_par
|
||||||
|
from public.holes h
|
||||||
|
where h.course_id = new.course_id;
|
||||||
|
|
||||||
|
select c.par into course_par
|
||||||
|
from public.courses c
|
||||||
|
where c.id = new.course_id;
|
||||||
|
|
||||||
|
-- Only enforce once all holes are entered (9 or 18)
|
||||||
|
if hole_count in (9, 18) then
|
||||||
|
if total_par <> course_par then
|
||||||
|
raise exception 'Sum of hole pars (%) does not equal course par (%)',
|
||||||
|
total_par, course_par;
|
||||||
|
end if;
|
||||||
|
end if;
|
||||||
|
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger enforce_course_par
|
||||||
|
after insert or update on public.holes
|
||||||
|
for each row execute procedure public.check_course_par();
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Rounds
|
||||||
|
-- ============================================================
|
||||||
|
create type public.round_status as enum ('lobby', 'active', 'completed');
|
||||||
|
|
||||||
|
create table public.rounds (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
course_id uuid not null references public.courses,
|
||||||
|
tee_id uuid not null references public.tees,
|
||||||
|
name text,
|
||||||
|
date date not null default current_date,
|
||||||
|
holes_count int not null check (holes_count in (9, 18)),
|
||||||
|
status public.round_status not null default 'lobby',
|
||||||
|
created_by uuid not null references public.profiles,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Players in a round
|
||||||
|
create table public.round_players (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
round_id uuid not null references public.rounds on delete cascade,
|
||||||
|
user_id uuid not null references public.profiles on delete cascade,
|
||||||
|
role text not null default 'player' check (role in ('admin', 'player')),
|
||||||
|
handicap_index numeric(4,1),
|
||||||
|
course_handicap int,
|
||||||
|
joined_at timestamptz not null default now(),
|
||||||
|
unique (round_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Round invitations
|
||||||
|
create table public.round_invites (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
round_id uuid not null references public.rounds on delete cascade,
|
||||||
|
email text not null,
|
||||||
|
invited_by uuid not null references public.profiles,
|
||||||
|
accepted_at timestamptz,
|
||||||
|
expires_at timestamptz not null default (now() + interval '7 days'),
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Scores
|
||||||
|
-- ============================================================
|
||||||
|
create table public.scores (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
round_id uuid not null references public.rounds on delete cascade,
|
||||||
|
hole_id uuid not null references public.holes on delete cascade,
|
||||||
|
player_id uuid not null references public.profiles on delete cascade,
|
||||||
|
strokes int not null check (strokes > 0),
|
||||||
|
updated_by uuid not null references public.profiles,
|
||||||
|
updated_at timestamptz not null default now(),
|
||||||
|
unique (round_id, hole_id, player_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Update updated_at on score change
|
||||||
|
create or replace function public.set_score_updated_at()
|
||||||
|
returns trigger language plpgsql as $$
|
||||||
|
begin
|
||||||
|
new.updated_at = now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
create trigger scores_updated_at
|
||||||
|
before update on public.scores
|
||||||
|
for each row execute procedure public.set_score_updated_at();
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- MulliganMates — Row Level Security Policies
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
alter table public.profiles enable row level security;
|
||||||
|
alter table public.courses enable row level security;
|
||||||
|
alter table public.tees enable row level security;
|
||||||
|
alter table public.holes enable row level security;
|
||||||
|
alter table public.rounds enable row level security;
|
||||||
|
alter table public.round_players enable row level security;
|
||||||
|
alter table public.round_invites enable row level security;
|
||||||
|
alter table public.scores enable row level security;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Helper: is the current user a member of the given round?
|
||||||
|
-- ============================================================
|
||||||
|
create or replace function public.is_round_member(p_round_id uuid)
|
||||||
|
returns boolean language sql security definer stable as $$
|
||||||
|
select exists (
|
||||||
|
select 1 from public.round_players
|
||||||
|
where round_id = p_round_id
|
||||||
|
and user_id = auth.uid()
|
||||||
|
)
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Profiles
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Anyone can read profiles"
|
||||||
|
on public.profiles for select using (true);
|
||||||
|
|
||||||
|
create policy "Users can update their own profile"
|
||||||
|
on public.profiles for update using (id = auth.uid());
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Courses
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Authenticated users can read courses"
|
||||||
|
on public.courses for select using (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Authenticated users can create courses"
|
||||||
|
on public.courses for insert with check (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Course creator can update"
|
||||||
|
on public.courses for update using (created_by = auth.uid());
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Tees
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Authenticated users can read tees"
|
||||||
|
on public.tees for select using (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Authenticated users can manage tees on their courses"
|
||||||
|
on public.tees for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.courses
|
||||||
|
where id = tees.course_id
|
||||||
|
and created_by = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Holes
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Authenticated users can read holes"
|
||||||
|
on public.holes for select using (auth.role() = 'authenticated');
|
||||||
|
|
||||||
|
create policy "Course creator can manage holes"
|
||||||
|
on public.holes for all using (
|
||||||
|
exists (
|
||||||
|
select 1 from public.courses
|
||||||
|
where id = holes.course_id
|
||||||
|
and created_by = auth.uid()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Rounds
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Round members can read round"
|
||||||
|
on public.rounds for select using (public.is_round_member(id));
|
||||||
|
|
||||||
|
create policy "Authenticated users can create rounds"
|
||||||
|
on public.rounds for insert with check (
|
||||||
|
auth.role() = 'authenticated' and created_by = auth.uid()
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Round creator can update round"
|
||||||
|
on public.rounds for update using (created_by = auth.uid());
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Round Players
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Round members can read player list"
|
||||||
|
on public.round_players for select using (public.is_round_member(round_id));
|
||||||
|
|
||||||
|
create policy "Round creator can add players"
|
||||||
|
on public.round_players for insert with check (
|
||||||
|
exists (
|
||||||
|
select 1 from public.rounds
|
||||||
|
where id = round_players.round_id
|
||||||
|
and created_by = auth.uid()
|
||||||
|
and status = 'lobby'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Users can join a round via insert of own record"
|
||||||
|
on public.round_players for insert with check (user_id = auth.uid());
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Round Invites
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Round members can read invites"
|
||||||
|
on public.round_invites for select using (public.is_round_member(round_id));
|
||||||
|
|
||||||
|
create policy "Round creator can send invites"
|
||||||
|
on public.round_invites for insert with check (
|
||||||
|
exists (
|
||||||
|
select 1 from public.rounds
|
||||||
|
where id = round_invites.round_id
|
||||||
|
and created_by = auth.uid()
|
||||||
|
and status = 'lobby'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Invited user can mark invite accepted"
|
||||||
|
on public.round_invites for update using (
|
||||||
|
email = (select email from auth.users where id = auth.uid())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- ============================================================
|
||||||
|
-- Scores
|
||||||
|
-- ============================================================
|
||||||
|
create policy "Round members can read scores"
|
||||||
|
on public.scores for select using (public.is_round_member(round_id));
|
||||||
|
|
||||||
|
create policy "Round members can enter and edit scores"
|
||||||
|
on public.scores for insert with check (
|
||||||
|
public.is_round_member(round_id) and
|
||||||
|
exists (
|
||||||
|
select 1 from public.rounds
|
||||||
|
where id = scores.round_id
|
||||||
|
and status = 'active'
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
create policy "Round members can update scores"
|
||||||
|
on public.scores for update using (
|
||||||
|
public.is_round_member(round_id) and
|
||||||
|
exists (
|
||||||
|
select 1 from public.rounds
|
||||||
|
where id = scores.round_id
|
||||||
|
and status = 'active'
|
||||||
|
)
|
||||||
|
);
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts",
|
||||||
|
".next/dev/types/**/*.ts",
|
||||||
|
"**/*.mts"
|
||||||
|
],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user