From 4897d3003fcb5d18ce3fe173d77bad2063c4af65 Mon Sep 17 00:00:00 2001 From: Rolf Date: Wed, 18 Mar 2026 12:37:17 +0100 Subject: [PATCH] Initial commit v1 --- CLAUDE.md | 92 + Dockerfile | 37 + README.md | 36 + app/(app)/courses/new/page.tsx | 366 + app/(app)/dashboard/page.tsx | 98 + app/(app)/layout.tsx | 23 + app/(app)/rounds/[id]/join/page.tsx | 43 + app/(app)/rounds/[id]/page.tsx | 63 + app/(app)/rounds/[id]/scorecard/page.tsx | 104 + app/(app)/rounds/new/page.tsx | 28 + app/(auth)/confirm/route.ts | 19 + app/(auth)/login/page.tsx | 85 + app/actions/courses.ts | 62 + app/actions/invites.ts | 50 + app/actions/rounds.ts | 129 + app/actions/scores.ts | 45 + app/favicon.ico | Bin 0 -> 25931 bytes app/globals.css | 129 + app/layout.tsx | 34 + app/manifest.ts | 32 + app/page.tsx | 65 + components.json | 25 + components/bottom-nav.tsx | 45 + components/round/join-round.tsx | 123 + components/round/new-round-wizard.tsx | 312 + components/round/round-lobby.tsx | 233 + components/scorecard/full-grid.tsx | 174 + components/scorecard/hole-view.tsx | 168 + components/scorecard/leaderboard-view.tsx | 135 + components/scorecard/score-entry-sheet.tsx | 131 + components/scorecard/scorecard-view.tsx | 220 + components/ui/button.tsx | 60 + components/ui/input.tsx | 20 + components/ui/label.tsx | 20 + docker-compose.yml | 255 + eslint.config.mjs | 18 + lib/admin.ts | 8 + lib/handicap.ts | 47 + lib/supabase/client.ts | 9 + lib/supabase/server.ts | 51 + lib/supabase/types.ts | 10 + lib/utils.ts | 6 + middleware.ts | 58 + next.config.ts | 22 + package-lock.json | 10148 +++++++++++++++++++ package.json | 43 + postcss.config.mjs | 7 + public/file.svg | 1 + public/globe.svg | 1 + public/next.svg | 1 + public/vercel.svg | 1 + public/window.svg | 1 + supabase/migrations/00001_schema.sql | 159 + supabase/migrations/00002_rls.sql | 155 + tsconfig.json | 34 + 55 files changed, 14241 insertions(+) create mode 100644 CLAUDE.md create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 app/(app)/courses/new/page.tsx create mode 100644 app/(app)/dashboard/page.tsx create mode 100644 app/(app)/layout.tsx create mode 100644 app/(app)/rounds/[id]/join/page.tsx create mode 100644 app/(app)/rounds/[id]/page.tsx create mode 100644 app/(app)/rounds/[id]/scorecard/page.tsx create mode 100644 app/(app)/rounds/new/page.tsx create mode 100644 app/(auth)/confirm/route.ts create mode 100644 app/(auth)/login/page.tsx create mode 100644 app/actions/courses.ts create mode 100644 app/actions/invites.ts create mode 100644 app/actions/rounds.ts create mode 100644 app/actions/scores.ts create mode 100644 app/favicon.ico create mode 100644 app/globals.css create mode 100644 app/layout.tsx create mode 100644 app/manifest.ts create mode 100644 app/page.tsx create mode 100644 components.json create mode 100644 components/bottom-nav.tsx create mode 100644 components/round/join-round.tsx create mode 100644 components/round/new-round-wizard.tsx create mode 100644 components/round/round-lobby.tsx create mode 100644 components/scorecard/full-grid.tsx create mode 100644 components/scorecard/hole-view.tsx create mode 100644 components/scorecard/leaderboard-view.tsx create mode 100644 components/scorecard/score-entry-sheet.tsx create mode 100644 components/scorecard/scorecard-view.tsx create mode 100644 components/ui/button.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 docker-compose.yml create mode 100644 eslint.config.mjs create mode 100644 lib/admin.ts create mode 100644 lib/handicap.ts create mode 100644 lib/supabase/client.ts create mode 100644 lib/supabase/server.ts create mode 100644 lib/supabase/types.ts create mode 100644 lib/utils.ts create mode 100644 middleware.ts create mode 100644 next.config.ts create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 postcss.config.mjs create mode 100644 public/file.svg create mode 100644 public/globe.svg create mode 100644 public/next.svg create mode 100644 public/vercel.svg create mode 100644 public/window.svg create mode 100644 supabase/migrations/00001_schema.sql create mode 100644 supabase/migrations/00002_rls.sql create mode 100644 tsconfig.json diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6d8f857 --- /dev/null +++ b/CLAUDE.md @@ -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 + +# Add a shadcn/ui component +docker run --rm -v $(pwd):/app -w /app node:22-alpine npx shadcn@latest add + +# 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..aa72b4e --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..e215bc4 --- /dev/null +++ b/README.md @@ -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. diff --git a/app/(app)/courses/new/page.tsx b/app/(app)/courses/new/page.tsx new file mode 100644 index 0000000..53a3700 --- /dev/null +++ b/app/(app)/courses/new/page.tsx @@ -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(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([]) + const [teeForm, setTeeForm] = useState({ + name: '', + color: '#2563EB', + course_rating: '', + slope_rating: '', + }) + + // Step 3 + const [holes, setHoles] = useState(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 ( +
+ {/* Header */} +
+ +

New course

+
+ {steps.map((s, i) => ( +
+ ))} +
+
+ +
+ {/* Step 1: Course basics */} + {step === 1 && ( +
+

Course details

+ +
+ + setName(e.target.value)} + placeholder="e.g. Augusta National" + autoFocus + /> +
+ +
+ +
+ {([9, 18] as const).map((n) => ( + + ))} +
+
+ +
+ + setPar(e.target.value)} + min={27} + max={90} + /> +
+ + +
+ )} + + {/* Step 2: Tees */} + {step === 2 && ( +
+

Tees

+ + {/* Existing tees */} + {tees.length > 0 && ( +
+ {tees.map((t, i) => ( +
+
+
+
+
{t.name}
+
+ Rating {t.course_rating} · Slope {t.slope_rating} +
+
+
+ +
+ ))} +
+ )} + + {/* Add tee form */} +
+

Add tee

+ +
+ +
+ {TEE_COLORS.map((c) => ( +
+
+ +
+
+ + setTeeForm({ ...teeForm, name: e.target.value })} + placeholder="e.g. White" + /> +
+
+ + setTeeForm({ ...teeForm, course_rating: e.target.value })} + placeholder="71.5" + step="0.1" + /> +
+
+ + setTeeForm({ ...teeForm, slope_rating: e.target.value })} + placeholder="125" + /> +
+
+ + +
+ + +
+ )} + + {/* Step 3: Holes */} + {step === 3 && ( +
+
+

Holes

+ + Par sum: {parSum} / {parTarget} + +
+ +
+ + + + + + + + + + {holes.slice(0, holesCount).map((hole, i) => ( + + + + + + ))} + +
HoleParSI
{hole.hole_number} +
+ {[3, 4, 5].map((p) => ( + + ))} +
+
+ 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" + /> +
+
+ + {error &&

{error}

} + + +
+ )} +
+
+ ) +} diff --git a/app/(app)/dashboard/page.tsx b/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..2b3dad1 --- /dev/null +++ b/app/(app)/dashboard/page.tsx @@ -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 ( +
+
+

Rounds

+ + New round + +
+ + {!rounds?.length && ( +

+ No active rounds. Start one! +

+ )} + +
+ {rounds?.map(({ round }) => { + if (!round) return null + return ( + +
+ {round.course?.name} + +
+
+ {round.name && {round.name} · } + {round.tee?.name} tees · {round.holes_count} holes ·{' '} + {new Date(round.date).toLocaleDateString()} +
+ + ) + })} +
+
+ ) +} + +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 ( + + {status} + + ) +} diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx new file mode 100644 index 0000000..58f4086 --- /dev/null +++ b/app/(app)/layout.tsx @@ -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 ( +
+
{children}
+ +
+ ) +} diff --git a/app/(app)/rounds/[id]/join/page.tsx b/app/(app)/rounds/[id]/join/page.tsx new file mode 100644 index 0000000..7d2878e --- /dev/null +++ b/app/(app)/rounds/[id]/join/page.tsx @@ -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 +} diff --git a/app/(app)/rounds/[id]/page.tsx b/app/(app)/rounds/[id]/page.tsx new file mode 100644 index 0000000..6b34fd4 --- /dev/null +++ b/app/(app)/rounds/[id]/page.tsx @@ -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 ( + + ) +} diff --git a/app/(app)/rounds/[id]/scorecard/page.tsx b/app/(app)/rounds/[id]/scorecard/page.tsx new file mode 100644 index 0000000..7c98f6c --- /dev/null +++ b/app/(app)/rounds/[id]/scorecard/page.tsx @@ -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 ( + + ) +} + +async function getCourseId(db: any, roundId: string): Promise { + const { data } = await db + .from('rounds') + .select('course_id') + .eq('id', roundId) + .single() + return data?.course_id ?? '' +} diff --git a/app/(app)/rounds/new/page.tsx b/app/(app)/rounds/new/page.tsx new file mode 100644 index 0000000..afc5168 --- /dev/null +++ b/app/(app)/rounds/new/page.tsx @@ -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 +} diff --git a/app/(auth)/confirm/route.ts b/app/(auth)/confirm/route.ts new file mode 100644 index 0000000..4ea43ad --- /dev/null +++ b/app/(auth)/confirm/route.ts @@ -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`) +} diff --git a/app/(auth)/login/page.tsx b/app/(auth)/login/page.tsx new file mode 100644 index 0000000..e856f27 --- /dev/null +++ b/app/(auth)/login/page.tsx @@ -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(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 ( +
+
+
+

Check your email

+

+ We sent a magic link to {email}. Tap the link to + sign in. +

+
+
+ ) + } + + return ( +
+
+
+
+

MulliganMates

+

Sign in to track your round

+
+ +
+
+ + setEmail(e.target.value)} + required + autoComplete="email" + autoFocus + /> +
+ + {error &&

{error}

} + + +
+
+
+ ) +} diff --git a/app/actions/courses.ts b/app/actions/courses.ts new file mode 100644 index 0000000..824b999 --- /dev/null +++ b/app/actions/courses.ts @@ -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 + +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 } +} diff --git a/app/actions/invites.ts b/app/actions/invites.ts new file mode 100644 index 0000000..162a731 --- /dev/null +++ b/app/actions/invites.ts @@ -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 } +} diff --git a/app/actions/rounds.ts b/app/actions/rounds.ts new file mode 100644 index 0000000..957454a --- /dev/null +++ b/app/actions/rounds.ts @@ -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 + +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 } +} diff --git a/app/actions/scores.ts b/app/actions/scores.ts new file mode 100644 index 0000000..ba19fff --- /dev/null +++ b/app/actions/scores.ts @@ -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 } +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..a8da733 --- /dev/null +++ b/app/globals.css @@ -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; + } +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..f7fa87e --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/app/manifest.ts b/app/manifest.ts new file mode 100644 index 0000000..a46b0ac --- /dev/null +++ b/app/manifest.ts @@ -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', + }, + ], + } +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..295f8fd --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,65 @@ +import Image from "next/image"; + +export default function Home() { + return ( +
+
+ Next.js logo +
+

+ To get started, edit the page.tsx file. +

+

+ Looking for a starting point or more instructions? Head over to{" "} + + Templates + {" "} + or the{" "} + + Learning + {" "} + center. +

+
+ +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..f382eb7 --- /dev/null +++ b/components.json @@ -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": {} +} diff --git a/components/bottom-nav.tsx b/components/bottom-nav.tsx new file mode 100644 index 0000000..cdd231e --- /dev/null +++ b/components/bottom-nav.tsx @@ -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 ( + + ) +} diff --git a/components/round/join-round.tsx b/components/round/join-round.tsx new file mode 100644 index 0000000..7f3010b --- /dev/null +++ b/components/round/join-round.tsx @@ -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(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 ( +
+
+
+
+

You're invited!

+
+ +
+
{round.course?.name}
+
+ {round.name &&
{round.name}
} +
+
+ {round.tee?.name} tees · {round.holes_count} holes +
+
{new Date(round.date).toLocaleDateString()}
+
+
+ + {error &&

{error}

} + + +
+
+ ) +} diff --git a/components/round/new-round-wizard.tsx b/components/round/new-round-wizard.tsx new file mode 100644 index 0000000..b4015db --- /dev/null +++ b/components/round/new-round-wizard.tsx @@ -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(null) + const [loading, setLoading] = useState(false) + const [emailInput, setEmailInput] = useState('') + + const [data, setData] = useState({ + 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 ( +
+ {/* Header */} +
+ +

New round

+
+ {steps.map((s, i) => ( +
+ ))} +
+
+ +
+ {/* Step 1: Course selection */} + {step === 1 && ( +
+

Select course

+ + {courses.length === 0 ? ( +

+ No courses yet.{' '} + + Create one first. + +

+ ) : ( +
+ {courses.map((course) => ( + + ))} +
+ )} + + + + Add new course + +
+ )} + + {/* Step 2: Tee selection */} + {step === 2 && selectedCourse && ( +
+

Select tees

+

{selectedCourse.name}

+ +
+ {selectedCourse.tees.map((tee) => ( + + ))} +
+
+ )} + + {/* Step 3: Round details */} + {step === 3 && ( +
+

Round details

+ +
+ +
+ {([9, 18] as const).map((n) => ( + + ))} +
+
+ +
+ + setData({ ...data, date: e.target.value })} + /> +
+ +
+ + setData({ ...data, name: e.target.value })} + placeholder="e.g. Sunday morning four-ball" + /> +
+ + +
+ )} + + {/* Step 4: Invite players */} + {step === 4 && ( +
+

Invite players

+

+ Each player will receive a magic link by email.{' '} + You can skip this and invite later from the lobby. +

+ + {/* Email input */} +
+ setEmailInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && addEmail()} + placeholder="player@example.com" + className="flex-1" + /> + +
+ + {/* Email list */} + {data.emails.length > 0 && ( +
+ {data.emails.map((email) => ( +
+ {email} + +
+ ))} +
+ )} + + {error &&

{error}

} + + +
+ )} +
+
+ ) +} diff --git a/components/round/round-lobby.tsx b/components/round/round-lobby.tsx new file mode 100644 index 0000000..b385589 --- /dev/null +++ b/components/round/round-lobby.tsx @@ -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(null) + const [inviting, setInviting] = useState(false) + const [starting, setStarting] = useState(false) + const [startError, setStartError] = useState(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 ( +
+ {/* Header */} +
+ + + +
+

+ {round.name ?? round.course?.name ?? 'Round'} +

+

+ {round.course?.name} · {round.tee?.name} tees ·{' '} + {round.holes_count} holes ·{' '} + {new Date(round.date).toLocaleDateString()} +

+
+ + Lobby + +
+ +
+ {/* Players */} +
+

+ Players ({players.length}) +

+
+ {players.map((p) => ( +
+ +
+
+ + {p.profile?.display_name} + + {p.profile?.id === currentUserId && ( + (you) + )} + {p.role === 'admin' && ( + + creator + + )} +
+
+ {p.handicap_index !== null + ? `HCP ${p.handicap_index} · Course HCP ${p.course_handicap}` + : 'No handicap'} +
+
+
+ ))} +
+
+ + {/* Pending invites */} + {pendingInvites.length > 0 && ( +
+

+ Awaiting ({pendingInvites.length}) +

+ {pendingInvites.map((invite) => ( +
+ + {invite.email} +
+ ))} +
+ )} + + {/* Invite form (creator only, lobby only) */} + {isCreator && round.status === 'lobby' && ( +
+

Invite player

+
+ setEmailInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleInvite()} + placeholder="player@example.com" + className="flex-1" + /> + +
+ {inviteError && ( +

{inviteError}

+ )} +
+ )} + + {/* Start round button */} + {isCreator && round.status === 'lobby' && ( +
+ {startError && ( +

{startError}

+ )} + +
+ )} +
+
+ ) +} + +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 ( + {name} + ) + } + + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/full-grid.tsx b/components/scorecard/full-grid.tsx new file mode 100644 index 0000000..065612f --- /dev/null +++ b/components/scorecard/full-grid.tsx @@ -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 + 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 ( +
+ + + + + {front.map((h) => ( + + ))} + {hasBoth && ( + + )} + {back.map((h) => ( + + ))} + {hasBoth && ( + + )} + + + + {/* Par row */} + + + {front.map((h) => ( + + ))} + {hasBoth && ( + + )} + {back.map((h) => ( + + ))} + {hasBoth && ( + + )} + + + + + {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 ( + + + + {front.map((h) => { + const gross = getGross(player.user_id, h) + const grossVsPar = gross !== null ? gross - h.par : null + return ( + + ) + })} + + {hasBoth && ( + + )} + + {back.map((h) => { + const gross = getGross(player.user_id, h) + const grossVsPar = gross !== null ? gross - h.par : null + return ( + + ) + })} + + {hasBoth && ( + + )} + + + + + ) + })} + +
+ Player + + {h.hole_number} + Out + {h.hole_number} + InTotNet
Par + {h.par} + {parFront} + {h.par} + {parBack}{parTotal} +
+ {player.profile?.display_name} + isActive && onTapScore(player, h)} + > + + {gross ?? '·'} + + + {frontOut ?? '—'} + isActive && onTapScore(player, h)} + > + + {gross ?? '·'} + + + {backIn ?? '—'} + + {total ?? '—'} + + {netVsPar !== null ? formatVsPar(netVsPar) : '—'} +
+
+ ) +} diff --git a/components/scorecard/hole-view.tsx b/components/scorecard/hole-view.tsx new file mode 100644 index 0000000..0d72191 --- /dev/null +++ b/components/scorecard/hole-view.tsx @@ -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 + runningTotals: Record + 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(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 ( +
+ {/* Hole header */} +
+ + +
+
Hole {hole.hole_number}
+
+ Par {hole.par} · SI {hole.stroke_index} +
+
+ + +
+ + {/* Players */} +
+ {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 ( + + ) + })} +
+ + {/* Hole progress dots */} +
+ {Array.from({ length: totalHoles }, (_, i) => ( +
+ ))} +
+
+ ) +} + +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 {name} + } + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/leaderboard-view.tsx b/components/scorecard/leaderboard-view.tsx new file mode 100644 index 0000000..f41afa7 --- /dev/null +++ b/components/scorecard/leaderboard-view.tsx @@ -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 +} + +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 ( +
+ {standings.map(({ player, grossTotal, netVsPar, grossVsPar, holesPlayed }, i) => { + const isLead = i === 0 && netVsPar !== null + + return ( +
+ {/* Position */} +
+ {holesPlayed > 0 ? i + 1 : '—'} +
+ + {/* Avatar */} + + + {/* Name + holes played */} +
+
+ {player.profile?.display_name} +
+
+ {holesPlayed > 0 + ? `${holesPlayed} of ${totalHoles} holes` + : 'No scores yet'} + {player.course_handicap !== null && + ` · HCP ${player.course_handicap}`} +
+
+ + {/* Net score (primary, bold) */} +
+ + {netVsPar !== null ? formatVsPar(netVsPar) : '—'} + + {grossVsPar !== null && ( + + {grossTotal} gross + + )} +
+
+ ) + })} +
+ ) +} + +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 {name} + } + return ( +
+ {initials} +
+ ) +} diff --git a/components/scorecard/score-entry-sheet.tsx b/components/scorecard/score-entry-sheet.tsx new file mode 100644 index 0000000..6e2e78b --- /dev/null +++ b/components/scorecard/score-entry-sheet.tsx @@ -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(currentStrokes ?? hole?.par ?? 4) + const [editingNumber, setEditingNumber] = useState(false) + const inputRef = useRef(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 */} +
+ + {/* Sheet */} +
+ {/* Handle */} +
+
+
+ + {/* Player + hole info */} +
+
+
{player.profile?.display_name}
+
+ Hole {hole.hole_number} · Par {par} · SI {hole.stroke_index} +
+
+
0 ? 'text-muted-foreground' : 'text-foreground' + }`} + > + {vsPar === 0 ? 'Par' : vsPar > 0 ? `+${vsPar}` : vsPar} +
+
+ + {/* Score input */} +
+ {/* Minus */} + + + {/* Score display / input */} + {editingNumber ? ( + 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" + /> + ) : ( + + )} + + {/* Plus */} + +
+ + {/* Save */} +
+ +
+
+ + ) +} diff --git a/components/scorecard/scorecard-view.tsx b/components/scorecard/scorecard-view.tsx new file mode 100644 index 0000000..27b59f1 --- /dev/null +++ b/components/scorecard/scorecard-view.tsx @@ -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 { + 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>(buildScoreMap(initialScores)) + const [activeHoleIdx, setActiveHoleIdx] = useState(0) + const [view, setView] = useState('hole') + const [entry, setEntry] = useState(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 = {} + 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 ( +
+ {/* Header */} +
+ + + +
+
+ {round.name ?? round.course?.name ?? 'Scorecard'} +
+
+ {round.tee?.name} tees · {round.holes_count} holes + {!isActive && ( + (completed) + )} +
+
+ {saving && ( + Saving… + )} +
+ + {/* View tabs */} +
+ {(['hole', 'grid', 'leaderboard'] as View[]).map((v) => ( + + ))} +
+ + {/* Main content */} +
+ {view === 'hole' && activeHole && ( + setActiveHoleIdx((i) => Math.max(0, i - 1))} + onNext={() => setActiveHoleIdx((i) => Math.min(holes.length - 1, i + 1))} + onTapScore={(player) => openEntry(player)} + /> + )} + + {view === 'grid' && ( +
+ openEntry(player, hole)} + /> +
+ )} + + {view === 'leaderboard' && ( +
+ +
+ )} +
+ + {/* Score entry sheet */} + setEntry(null)} + /> +
+ ) +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..ded01b2 --- /dev/null +++ b/components/ui/button.tsx @@ -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) { + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..7d21bab --- /dev/null +++ b/components/ui/input.tsx @@ -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 ( + + ) +} + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..74da65c --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,20 @@ +"use client" + +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Label({ className, ...props }: React.ComponentProps<"label">) { + return ( +