Prototype app made by google ai studio
This commit is contained in:
commit
f0ad8481c4
51 files changed
+12777
No files matched your search
@@ -0,0 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.gmp_cache
|
||||
.env
|
||||
.env.local
|
||||
.DS_Store
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.aistudio
|
||||
@@ -0,0 +1,33 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
|
||||
# VITE_GOOGLE_MAPS_API_KEY: Required for Google Maps satellite imagery,
|
||||
# live GPS hole rangefinding (front/middle/back green distances), and course visualization.
|
||||
VITE_GOOGLE_MAPS_API_KEY="YOUR_GOOGLE_MAPS_API_KEY"
|
||||
|
||||
# JWT_SECRET: Secret key used to sign session JWT tokens
|
||||
JWT_SECRET="pinpoint_golf_super_secret_jwt_key_2026"
|
||||
|
||||
# ========================================================
|
||||
# OAuth & SSO Configuration (Optional)
|
||||
# ========================================================
|
||||
|
||||
# Google OAuth 2.0 Credentials
|
||||
GOOGLE_CLIENT_ID=""
|
||||
GOOGLE_CLIENT_SECRET=""
|
||||
|
||||
# Authentik / OIDC Configuration (Self-Hosted Homelab or Enterprise SSO)
|
||||
AUTHENTIK_ISSUER_URL="https://authentik.company.local/application/o/pinpoint-golf/"
|
||||
AUTHENTIK_CLIENT_ID=""
|
||||
AUTHENTIK_CLIENT_SECRET=""
|
||||
|
||||
# GitHub OAuth Credentials
|
||||
GITHUB_CLIENT_ID=""
|
||||
GITHUB_CLIENT_SECRET=""
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: compliance-review
|
||||
description: Use this skill to review implementation plans or code that uses Google Maps Platform in order to identify revisions that need to be made based on Google Maps Platform Terms of Service, Acceptable Use Policy, EEA terms, AI terms, and deprecation policies. Use this skill each time there is a significant revision to an implementation plan or code such as a change in the Google Maps Platform products selected or the handling of responses from Google Maps Platform.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
version: 1.1.0
|
||||
---
|
||||
|
||||
# Google Maps Platform: Terms of Service Check Methodology
|
||||
|
||||
This skill provides a step-by-step, methodical procedure for verifying that a
|
||||
customer application's architectural design and code conform to all applicable
|
||||
Google Maps Platform and Google Cloud Platform (GCP) terms, policies, and
|
||||
regional restrictions.
|
||||
|
||||
## Methodical Compliance Review Procedure
|
||||
|
||||
Follow these four steps sequentially to evaluate any Google Maps Platform
|
||||
proposal, code suggestion, or architectural design.
|
||||
|
||||
### Step 1: Regional & Territory Restriction Check
|
||||
|
||||
Verify whether the application's intended target audience, deployment, or
|
||||
billing profile conflicts with regional restrictions.
|
||||
|
||||
1. **Prohibited Territories Check:** Confirm the Customer Application is not
|
||||
distributed, marketed, or functional within any of the prohibited
|
||||
territories.
|
||||
* **Prohibited Territories:** China, Crimea, Cuba, Donetsk People's
|
||||
Republic, Iran, Luhansk People's Republic, North Korea, Syria, and
|
||||
Vietnam.
|
||||
* *Reference:*
|
||||
[Google Maps Platform Prohibited Territories](https://cloud.google.com/maps-platform/terms/maps-prohibited-territories?utm_campaign=gmp_git_agentskills_v1)
|
||||
2. **Regional Terms Check:** If the customer billing address is in one of the
|
||||
regions listed in the Regional Terms section of the Google Maps Platform
|
||||
Terms of Service, those terms supersede the main Google Maps Platform Terms
|
||||
of Service.
|
||||
* *Reference:*
|
||||
[Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
3. **European Economic Area (EEA) Check:** Determine if the billing account
|
||||
address of the customer is within the EEA (EU Member States + Iceland,
|
||||
Liechtenstein, and Norway). If yes, apply EEA-specific rules:
|
||||
* **Relaxed Rules:** Under the FCO4 commitment (active until July 8,
|
||||
2035), EEA customers are not subject to standard restrictions
|
||||
prohibiting:
|
||||
1. Re-creating Google products/features
|
||||
2. Combining Google Maps Platform content with non-Google maps (subject
|
||||
to specific exceptions)
|
||||
3. Using Google Maps Platform in embedded vehicle systems
|
||||
* **Strict Places API Rules:** Non-geospatial Places data (excluding
|
||||
lat/lng and place_id) is restricted to the specific permitted use cases
|
||||
(such as address autocompletion, store finders, social tagging).
|
||||
* **No Decision-Making:** Results from the Places Aggregate API must not
|
||||
be used to make decisions impacting individuals' rights (e.g., credit,
|
||||
housing, employment, or insurance scores).
|
||||
* *Reference:*
|
||||
[Google Maps Platform EEA Terms of Service](https://cloud.google.com/terms/maps-platform/eea?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Google Maps Platform EEA Service Specific Terms](https://cloud.google.com/terms/maps-platform/eea/maps-service-terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Places API EEA Permitted Uses](https://cloud.google.com/terms/maps-platform/eea-places-api-permitted-uses?utm_campaign=gmp_git_agentskills_v1)
|
||||
|
||||
### Step 2: Generative AI & Model Training Check
|
||||
|
||||
Assess whether the project introduces artificial intelligence, machine learning,
|
||||
or generative model integrations.
|
||||
|
||||
1. **No Derived Content / Training Prohibition:** Ensure that Google Maps
|
||||
Content (including business names, reviews, coordinates, or elevation
|
||||
models) is **never** used to train, fine-tune, or improve machine learning
|
||||
or AI models.
|
||||
2. **AI Integration Terms:** If the application uses Generative AI (e.g.,
|
||||
Gemini API) alongside Google Maps Platform data, verify compliance with both
|
||||
AI-specific terms and standard cloud guidelines.
|
||||
* *Reference:*
|
||||
[Google Generative AI Additional Terms of Service](https://policies.google.com/terms/generative-ai?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Google Cloud Service Specific Terms (Generative AI Section)](https://cloud.google.com/terms/service-terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
|
||||
For projects involving artificial intelligence and generative content, consider
|
||||
Grounding products and Agentic UI toolkit listed at
|
||||
http://developers.google.com/maps/ai.
|
||||
|
||||
### Step 3: Layered Document Checklist (The 5 Pillars)
|
||||
|
||||
Methodically cross-reference the application's features against the five core
|
||||
governing documents of the Google Maps Platform ecosystem.
|
||||
|
||||
1. **Google Maps Platform Terms of Service (Main ToS):** Check billing
|
||||
requirements, basic license grants, and general usage rights.
|
||||
* *Reference:*
|
||||
[Google Maps Platform Terms of Service](https://cloud.google.com/maps-platform/terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
2. **Maps Service Specific Terms:** Verify product-specific restrictions:
|
||||
* **Caching Limits:** Check if caching is permitted. Geospatial data
|
||||
(lat/lng) is typically limited to **30 consecutive calendar days**.
|
||||
Check the specific table for environmental APIs (Air Quality, Solar,
|
||||
Pollen, Weather).
|
||||
* **No Scraping:** Ensure bulk downloads or indexing of Google Maps
|
||||
Content is not proposed.
|
||||
* *Reference:*
|
||||
[Maps Service Specific Terms](https://cloud.google.com/maps-platform/terms/maps-service-terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
3. **Google Cloud Platform Terms of Service (Cloud ToS):** Check general GCP
|
||||
rules, resource quotas, and API credential management.
|
||||
* *Reference:*
|
||||
[Google Cloud Platform Terms of Service](https://cloud.google.com/terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
4. **Google Privacy Policy & End User Terms:** Confirm that the application's
|
||||
terms explicitly state that it integrates Google Maps features and is
|
||||
subject to the End User Terms. Verify that location tracking requires
|
||||
explicit, revocable end-user consent.
|
||||
* *Reference:*
|
||||
[Google Maps End User Additional Terms of Service](https://maps.google.com/help/terms_maps/?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Google Privacy Policy](https://policies.google.com/privacy?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Google Controller-Controller Data Protection Terms](https://business.safety.google/controllerterms/?utm_campaign=gmp_git_agentskills_v1)
|
||||
5. **Google Acceptable Use Policy (AUP):** Ensure that the service is not used
|
||||
for prohibited use cases:
|
||||
* **High Risk Activities:** Banned from emergency services, autonomous
|
||||
vehicle routing, aviation, and nuclear facility control.
|
||||
* **COPPA:** Banned from child-directed applications.
|
||||
* **Sensitive Data:** Banned from handling ITAR-regulated or
|
||||
HIPAA-regulated medical records.
|
||||
* *Reference:*
|
||||
[Google Maps Platform Acceptable Use Policy](https://cloud.google.com/maps-platform/terms/aup?utm_campaign=gmp_git_agentskills_v1)
|
||||
|
||||
### Step 4: Lifecycle, SLA, and Deprecation Review
|
||||
|
||||
Identify the support commitments and deprecation risks for all APIs proposed in
|
||||
the architecture.
|
||||
|
||||
1. **Launch Stages Verification:** Identify if the APIs proposed are General
|
||||
Availability (GA) or Pre-GA (Preview, Alpha, Beta).
|
||||
* **Pre-GA Rules:** Pre-GA offerings are provided "as-is", do not qualify
|
||||
for Service Level Agreements (SLAs), and do not carry technical support
|
||||
commitments.
|
||||
* *Reference:*
|
||||
[Google Cloud Launch Stages](https://cloud.google.com/terms/launch-stages?utm_campaign=gmp_git_agentskills_v1)
|
||||
2. **Service Level Agreement (SLA):** For GA Core Services, verify the Uptime
|
||||
SLO (generally 99.9%).
|
||||
* *Reference:*
|
||||
[Google Maps Platform SLA](https://cloud.google.com/maps-platform/terms/sla?utm_campaign=gmp_git_agentskills_v1)
|
||||
* *Reference:*
|
||||
[Google Maps Platform Core Services Summary](https://cloud.google.com/maps-platform/terms/maps-services?utm_campaign=gmp_git_agentskills_v1)
|
||||
3. **Deprecation Policy:** GA Core Services are subject to deprecation
|
||||
protection, requiring a deprecation notice (typically 12 months) before
|
||||
decommissioning.
|
||||
* *Reference:*
|
||||
[Google Cloud Service Specific Terms (Deprecation Policy)](https://cloud.google.com/terms/service-terms?utm_campaign=gmp_git_agentskills_v1)
|
||||
4. **Open Source Client Library Exception:** Proactively alert developers that
|
||||
the standard enterprise deprecation policies **do not apply** to
|
||||
Google-maintained open-source client libraries. These libraries are updated
|
||||
under semantic versioning and the OSS Library Breaking Change Policy,
|
||||
meaning breaking changes are signaled via major version bumps (e.g., v1 to
|
||||
v2) rather than a formal deprecation schedule.
|
||||
* *Reference:*
|
||||
[Google Open Source Library Breaking Change Policy](https://opensource.google/documentation/reference/releasing/versioning?utm_campaign=gmp_git_agentskills_v1)
|
||||
|
||||
## Technical Support Guidelines
|
||||
|
||||
* Refer to the
|
||||
[Technical Support Services Guidelines (TSSG)](https://cloud.google.com/maps-platform/terms/tssg?utm_campaign=gmp_git_agentskills_v1)
|
||||
to check eligibility for ticket logging, response times, and escalations.
|
||||
@@ -0,0 +1,406 @@
|
||||
---
|
||||
name: gmp-framework-react
|
||||
description: This guide serves as the mandatory entry point and architectural router for building Google Maps Platform applications using the **React** framework, specifically leveraging the official `@vis.gl/react-google-maps` SDK and modern API features. The content is rigorously curated to enforce the **Absolute Universal Zero-Legacy Mandate**, ensuring all generated solutions utilize current, future-proof, cost-optimized, and performant APIs. Developers are proactively guided toward modern replacements like `AdvancedMarkerElement`, the `Place` class (Places API New), and the `Route` class (Routes API), strictly forbidding any reliance on deprecated or disabled callback-based services. This router ensures comprehensive coverage of React patterns, from basic initialization to complex data visualization and clustering techniques.
|
||||
license: Apache-2.0
|
||||
metadata:
|
||||
version: 1.0.69
|
||||
---
|
||||
|
||||
> [!IMPORTANT] **Core Dependency:** This skill requires active context from
|
||||
> [google-maps-platform/SKILL.md](https://www.gstatic.com/googlemapsplatform-agent-skills/google-maps-platform/SKILL.md).
|
||||
|
||||
# Google Maps Platform for React Developers
|
||||
|
||||
This document details the recommended architectural patterns and reference
|
||||
implementations for integrating Google Maps Platform into React applications
|
||||
using the official **@vis.gl/react-google-maps** SDK. All solutions presented
|
||||
adhere to the zero-legacy mandate, utilizing only modern, promise-based APIs.
|
||||
|
||||
## Architecture and Core Principles
|
||||
|
||||
The Google Maps Platform engineering team mandates the use of
|
||||
**`@vis.gl/react-google-maps`** for all React implementations. This SDK provides
|
||||
declarative component wrappers and specialized hooks for managing the map state
|
||||
and interacting with core libraries.
|
||||
|
||||
**Core Principles for React Development:**
|
||||
|
||||
1. **Modern SDK Only:** Use `@vis.gl/react-google-maps` exclusively. Libraries
|
||||
like `react-google-maps/api` or `google-map-react` are prohibited. (CF5)
|
||||
2. **Zero-Legacy Enforcement:** Never use callback-based services
|
||||
(`DirectionsService`, `PlacesService`, `Marker`). Use the modern,
|
||||
promise-based equivalents (Routes API, Places API New,
|
||||
`AdvancedMarkerElement`).
|
||||
3. **Map ID Requirement:** When using `AdvancedMarkerElement`, a `mapId` (e.g.,
|
||||
`"DEMO_MAP_ID"`) is mandatory on the `<Map>` component. (CF9)
|
||||
4. **Imperative Web Components:** When integrating Google Maps Platform Web
|
||||
Components (e.g., `PlaceAutocompleteElement`) into React, they must be
|
||||
mounted imperatively using `useRef` and `useEffect` to ensure complex
|
||||
objects are passed correctly, avoiding the JSX attribute stringification
|
||||
trap. (CF8)
|
||||
|
||||
## Table of Contents
|
||||
|
||||
The following references provide detailed, modern implementation patterns for
|
||||
the React framework:
|
||||
|
||||
### I. Initialization and Core Setup
|
||||
|
||||
Topic | Description | Reference Link
|
||||
:-------------------------- | :------------------------------------------------------------------------------------------ | :-------------
|
||||
**Basic Rendering** | Step-by-step guide for basic map initialization and rendering within a React component. | [Basic Initialization and Rendering of Google Maps in React](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_basic.md)
|
||||
**API Provider Pattern** | Comprehensive pattern for API key loading, context provision, and library loading in React. | [React Wrapper Pattern for Google Maps Platform API Initialization](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_react.md)
|
||||
**Next.js Integration** | Specific initialization and rendering patterns optimized for Next.js environments. | [Core Initialization and Declarative Map Rendering in Next.js](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_nextjs.md)
|
||||
**Remix Integration** | Integration and modern SDK patterns tailored for the Remix framework. | [React/Remix Integration and Modern Google Maps Platform SDK Patterns](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_remix.md)
|
||||
**Multi-Map Sync** | Techniques for synchronizing camera state and viewport across multiple map instances. | [Multi-Map View Synchronization using React Camera State](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_multiple.md)
|
||||
**Styling & Configuration** | Dynamically changing map options, themes, and styles post-initialization. | [Dynamic Map Styling and Configuration in React Google Maps SDK](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_change.md)
|
||||
**External Libraries** | Patterns for integrating non-GMP libraries (like Terra Draw) and deferred initialization. | [Patterns for External Library Integration and Deferred Map Initialization using React Hooks](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_terra.md)
|
||||
|
||||
### II. Markers, Clustering, and Data Visualization
|
||||
|
||||
Topic | Description | Reference Link
|
||||
:----------------------------------- | :------------------------------------------------------------------------------------------------ | :-------------
|
||||
**Advanced Marker Usage** | Core usage patterns for the modern `AdvancedMarkerElement` and `InfoWindow`. | [Google Maps Platform React Marker and InfoWindow Usage Patterns](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_markers.md)
|
||||
**Advanced Interaction** | Handling custom interactions, z-index, and anchoring for `AdvancedMarkerElement` and InfoWindows. | [Advanced Marker Interaction, Z-Index Management, and InfoWindow Anchoring](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_advanced.md)
|
||||
**Advanced Clustering** | High-performance clustering solutions using the official marker clustering library. | [React Google Maps: Advanced Marker Clustering and State Management](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_marker.md)
|
||||
**Custom Clustering (Supercluster)** | Integrating external clustering logic (Supercluster) with map viewport hooks. | [React Custom Marker Clustering Pattern using Supercluster and `useMapViewport`](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_custom.md)
|
||||
**Web Worker Clustering** | Offloading heavy clustering logic to web workers for maximum performance. | [React Web Worker Marker Clustering using `useSuperclusterWorker` and Advanced Markers](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_worker.md)
|
||||
**Deck.gl Overlays** | Integrating high-performance 3D and 2D visualization layers using Deck.gl. | [Integrating Deck.gl Overlays with @vis.gl/react-google-maps](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_deckgl.md)
|
||||
|
||||
### III. Geospatial Services (Routes and Places API New)
|
||||
|
||||
Topic | Description | Reference Link
|
||||
:------------------------------ | :---------------------------------------------------------------------------------------------------------------------------------- | :-------------
|
||||
**Modern Routing (Routes API)** | **Mandatory** pattern for calculating and rendering routes using `Route.computeRoutes`. (Legacy `DirectionsService` is prohibited). | [Modern Route Calculation and Rendering in React using `@vis.gl/react-google-maps`](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_directions.md)
|
||||
**Routes API Patterns** | Detailed integration of the promise-based Routes API library in React. | [Google Maps Platform Routes API Integration Patterns in React](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_routes.md)
|
||||
**Place Autocomplete** | Implementing address search using the modern, promise-based `AutocompleteSuggestion` and `Place.fetchFields`. | [Modern React Autocomplete Patterns using `AutocompleteSuggestion` and `Place.fetchFields`](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_autocomplete.md)
|
||||
**Places UI Kit** | Wrapping the Places and 3D UI Kit web components in React for dynamic usage. | [React Wrapper Patterns for Google Maps Platform UI Kit (3D & Places)](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_places.md)
|
||||
|
||||
### IV. Specialized and Advanced Features
|
||||
|
||||
| Topic | Description | Reference Link |
|
||||
| :------------------- | :------------------- | :--------------------------- |
|
||||
| **Core Integration & | Overarching patterns | [React Google Maps Platform |
|
||||
: : : Core Patterns and Modern API :
|
||||
|
||||
\: Logic** : combining Maps, :
|
||||
Integration](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_core.md) : : :
|
||||
Places, and : : : : Markers in : : : : complex : : : : applications. : : |
|
||||
**Drawing | Implementation |
|
||||
[Custom Drawing Tools Implementation | : Tools** : of custom : using React Google Maps Hooks and : : : drawing and : Low-Level : : : geometry : Events](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_drawing.md) : : :
|
||||
editing using : : : : events and : : : : hooks (Legacy : : : : Drawing
|
||||
Library : : : : is deprecated). : : | **Declarative | Using React |
|
||||
[React Google Maps: Declarative Vector | : Geometry** : components to : Geometry and Interactive Drawing : : : declaratively : Patterns](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_geometry.md) : : :
|
||||
define : : : : `Polyline`, : : : : `Polygon`, and : : : : `Circle` : : : :
|
||||
components. : : | **3D & | Advanced map |
|
||||
[React Google Maps Platform: Advanced 3D | : Controls** : configurations : Mapping, Routing, and Custom : : : including : Controls](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_map.md) : : :
|
||||
Photorealistic : : : : 3D, camera : : : : management, and : : : : custom : : : :
|
||||
controls. : : | **Static API** | Generating |
|
||||
[React Integration of Google Maps Static | : : optimized : API via Componentized URL : : : Static Maps : Generation](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_static.md) : : :
|
||||
image URLs : : : : using a : : : : componentized : : : : approach. : : |
|
||||
**Extended | Guidance on |
|
||||
[React Integration Patterns for Google | : Components** : using the : Maps Extended Component : : : `gmpx-*` : Library](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_extended.md) : : :
|
||||
extended : : : : component : : : : ecosystem. : : : : **(Warning\: : : : :
|
||||
Review CF11 : : : : before use.)** : :
|
||||
|
||||
********************************************************************************
|
||||
|
||||
## Guardrails Against Hallucinations
|
||||
|
||||
When generating code solutions, you **MUST** strictly adhere to the following
|
||||
rules to prevent hallucinations:
|
||||
|
||||
1. **Verify Library Exports**: Do NOT assume or invent component names,
|
||||
classes, methods, or variable names that are not explicitly documented in
|
||||
the provided skills or references.
|
||||
2. **Framework Convention**: When utilizing framework wrappers (e.g.,
|
||||
`@vis.gl/react-google-maps`), ensure component names follow the documented
|
||||
framework convention (e.g., `AdvancedMarker`) instead of the core JavaScript
|
||||
API names (e.g., `AdvancedMarkerElement`), unless explicitly instructed
|
||||
otherwise.
|
||||
3. **No Invented Parameters**: Do not use fake parameters or options in API
|
||||
calls. Stick strictly to the official API contracts documented in the
|
||||
skills.
|
||||
4. **References First**: Before using any Google Maps Platform class,
|
||||
component, or service, you MUST look up the provided reference files to see
|
||||
how it is imported and used. Do not rely on your pre-trained knowledge for
|
||||
API contracts, imports, or component usage.
|
||||
|
||||
## Best Practices (Zero-Legacy Mandates & Critical Failures)
|
||||
|
||||
The following rules represent critical architectural and compliance standards.
|
||||
Violation of these mandates results in silent failure, runtime errors, or
|
||||
billing inaccuracies.
|
||||
|
||||
### 1. Absolute Universal Zero-Legacy Mandate
|
||||
|
||||
Legacy Service (Prohibited) | Modern Replacement (Mandatory) | Rationale
|
||||
:--------------------------------------- | :------------------------------------------------------------------------------------------ | :--------
|
||||
`google.maps.Marker` | **`AdvancedMarkerElement`** | Deprecated Feb 2024. Legacy markers fail on maps requiring a `mapId`.
|
||||
`DirectionsService`/`DirectionsRenderer` | **`Route.computeRoutes()`** (via `useMapsLibrary('routes')`) | Legacy services throw `LegacyApiNotActivatedMapError` and crash the map.
|
||||
`DistanceMatrixService` | **Routes API REST endpoint** (`routes.googleapis.com/distanceMatrix/v2:computeRouteMatrix`) | Legacy services are disabled for new projects.
|
||||
`PlacesService`/`AutocompleteService` | **`Place` Class** (`PlaceAutocompleteElement`, `fetchAutocompleteSuggestions`) | Legacy Places endpoints return no predictions and will be fully disabled.
|
||||
Client-side `fetch()` to Maps REST APIs | **Official SDK Wrappers or Server-Side Proxy** | CORS Trap (CF1): Client-side `fetch` is blocked for most modern REST endpoints.
|
||||
|
||||
### 2. Mandatory React Framework Conventions
|
||||
|
||||
* **Required SDK (CF5):** All solutions **MUST** use the official React
|
||||
wrapper: `@vis.gl/react-google-maps`.
|
||||
* **Web Component Imperativity (CF8):** When working with GMP Web Components
|
||||
(like `<gmp-place-autocomplete>`), use `useRef` and `useEffect` to set
|
||||
object properties on the underlying DOM element directly. Do not pass
|
||||
complex objects as JSX props, as React stringifies them.
|
||||
* **Map Height (CF2):** The `<Map>` component requires explicit CSS styling
|
||||
(e.g., `height: 100vh` or a sized parent) otherwise it collapses to 0x0
|
||||
pixels.
|
||||
* **`mapId` Requirement (CF9):** If using `AdvancedMarkerElement`, a `mapId`
|
||||
(Cloud-styled or `"DEMO_MAP_ID"`) is mandatory on the `<Map>` component.
|
||||
|
||||
### 3. Usage & Optimization
|
||||
|
||||
* **Cost Awareness:** Always utilize session tokens (e.g.,
|
||||
`AutocompleteSessionToken`) when chaining Autocomplete and Place Details
|
||||
requests to optimize billing.
|
||||
* **Attribution ID:** Ensure the attribution ID `gmp_git_agentskills_v1` is
|
||||
set on the `<APIProvider>` or `<Map>` component
|
||||
(`internalUsageAttributionIds={["gmp_git_agentskills_v1"]}`).
|
||||
* **Locale (CF10):** For non-local requests, explicitly set the `language` and
|
||||
`region` on the `<APIProvider>` to ensure consistent results.
|
||||
|
||||
### Mandatory settings
|
||||
|
||||
For all applications built using the React component framework, the internal
|
||||
usage attribution ID `gmp_git_agentskills_v1` is a mandatory property required
|
||||
for accurate usage tracking. This must be applied directly to the primary map
|
||||
components (`<Map>` or `<Map3D>`).
|
||||
|
||||
#### React Prop on `<Map>` or `<Map3D>` components
|
||||
|
||||
Apply the required attribution ID using the `internalUsageAttributionIds` array
|
||||
prop on the main mapping component.
|
||||
|
||||
```jsx
|
||||
// Used with the standard 2D map component
|
||||
<Map
|
||||
internalUsageAttributionIds={['gmp_git_agentskills_v1']}
|
||||
gestureHandling={'greedy'}
|
||||
disableDefaultUI
|
||||
>
|
||||
{/* Markers rendered here */}
|
||||
</Map>
|
||||
```
|
||||
|
||||
```jsx
|
||||
// Used with the 3D map component
|
||||
<Map3D
|
||||
internalUsageAttributionIds={['gmp_git_agentskills_v1']}
|
||||
mode="SATELLITE"
|
||||
defaultCenter={{lat: 40.7093, lng: -73.9968, altitude: 32}}
|
||||
/>
|
||||
```
|
||||
|
||||
## 🚀 Master Orchestration Integration Workflow
|
||||
|
||||
Follow this multi-phase sequential integration checklist to compose features
|
||||
robustly. For each phase, read the referenced sub-workflow reference file and
|
||||
satisfy its *Verification Checkpoint* before advancing.
|
||||
|
||||
### 📦 Phase 1: Core Initialization & Base Setup (Primary)
|
||||
|
||||
- [ ] **Step 1.1: Core Setup - React Google Maps Platform Core Patterns and
|
||||
Modern API Integration** Read
|
||||
[references/example_core.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_core.md).
|
||||
*Trigger Condition*: Always active during boilerplate layout or initial
|
||||
framework loading pass. *Verification Checkpoint*: Ensure the core react
|
||||
google maps platform core patterns and modern api integration structure is
|
||||
initialized properly, and verify functionality compiles without errors.
|
||||
|
||||
### 📦 Phase 2: Component Integration & Feature Layers (Supplemental)
|
||||
|
||||
- [ ] **Step 2.1: Feature Layer - React Google Maps: Advanced Marker
|
||||
Interaction, Z-Index Management, and InfoWindow Anchoring** Read
|
||||
[references/example_advanced.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_advanced.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react google maps: advanced marker interaction, z-index management, and
|
||||
infowindow anchoring features integration. *Verification Checkpoint*: Ensure
|
||||
the react google maps: advanced marker interaction, z-index management, and
|
||||
infowindow anchoring elements are rendered correctly, and verify event
|
||||
callbacks handle data safely.
|
||||
- [ ] **Step 2.2: Feature Layer - Modern React Autocomplete Patterns using
|
||||
`AutocompleteSuggestion` and `Place.fetchFields`** Read
|
||||
[references/example_autocomplete.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_autocomplete.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
modern react autocomplete patterns using `autocompletesuggestion` and
|
||||
`place.fetchfields` features integration. *Verification Checkpoint*: Ensure
|
||||
the modern react autocomplete patterns using `autocompletesuggestion` and
|
||||
`place.fetchfields` elements are rendered correctly, and verify event
|
||||
callbacks handle data safely.
|
||||
- [ ] **Step 2.3: Feature Layer - Basic Initialization and Rendering of Google
|
||||
Maps in React** Read
|
||||
[references/example_basic.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_basic.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
basic initialization and rendering of google maps in react features
|
||||
integration. *Verification Checkpoint*: Ensure the basic initialization and
|
||||
rendering of google maps in react elements are rendered correctly, and
|
||||
verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.4: Feature Layer - Dynamic Map Styling and Configuration in
|
||||
React Google Maps SDK** Read
|
||||
[references/example_change.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_change.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
dynamic map styling and configuration in react google maps sdk features
|
||||
integration. *Verification Checkpoint*: Ensure the dynamic map styling and
|
||||
configuration in react google maps sdk elements are rendered correctly, and
|
||||
verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.5: Feature Layer - React Custom Marker Clustering Pattern using
|
||||
Supercluster and `useMapViewport`** Read
|
||||
[references/example_custom.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_custom.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react custom marker clustering pattern using supercluster and
|
||||
`usemapviewport` features integration. *Verification Checkpoint*: Ensure the
|
||||
react custom marker clustering pattern using supercluster and
|
||||
`usemapviewport` elements are rendered correctly, and verify event callbacks
|
||||
handle data safely.
|
||||
- [ ] **Step 2.6: Feature Layer - Integrating Deck.gl Overlays with
|
||||
@vis.gl/react-google-maps** Read
|
||||
[references/example_deckgl.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_deckgl.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
integrating deck.gl overlays with @vis.gl/react-google-maps features
|
||||
integration. *Verification Checkpoint*: Ensure the integrating deck.gl
|
||||
overlays with @vis.gl/react-google-maps elements are rendered correctly, and
|
||||
verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.7: Feature Layer - Modern Route Calculation and Rendering in
|
||||
React using `@vis.gl/react-google-maps`** Read
|
||||
[references/example_directions.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_directions.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
modern route calculation and rendering in react using
|
||||
`@vis.gl/react-google-maps` features integration. *Verification Checkpoint*:
|
||||
Ensure the modern route calculation and rendering in react using
|
||||
`@vis.gl/react-google-maps` elements are rendered correctly, and verify
|
||||
event callbacks handle data safely.
|
||||
- [ ] **Step 2.8: Feature Layer - Custom Drawing Tools Implementation using
|
||||
React Google Maps Hooks and Low-Level Events** Read
|
||||
[references/example_drawing.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_drawing.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
custom drawing tools implementation using react google maps hooks and
|
||||
low-level events features integration. *Verification Checkpoint*: Ensure the
|
||||
custom drawing tools implementation using react google maps hooks and
|
||||
low-level events elements are rendered correctly, and verify event callbacks
|
||||
handle data safely.
|
||||
- [ ] **Step 2.9: Feature Layer - React Integration Patterns for Google Maps
|
||||
Extended Component Library** Read
|
||||
[references/example_extended.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_extended.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react integration patterns for google maps extended component library
|
||||
features integration. *Verification Checkpoint*: Ensure the react
|
||||
integration patterns for google maps extended component library elements are
|
||||
rendered correctly, and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.10: Feature Layer - React Google Maps: Declarative Vector
|
||||
Geometry and Interactive Drawing Patterns** Read
|
||||
[references/example_geometry.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_geometry.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react google maps: declarative vector geometry and interactive drawing
|
||||
patterns features integration. *Verification Checkpoint*: Ensure the react
|
||||
google maps: declarative vector geometry and interactive drawing patterns
|
||||
elements are rendered correctly, and verify event callbacks handle data
|
||||
safely.
|
||||
- [ ] **Step 2.11: Feature Layer - React Google Maps Platform: Advanced 3D
|
||||
Mapping, Routing, and Custom Controls** Read
|
||||
[references/example_map.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_map.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react google maps platform: advanced 3d mapping, routing, and custom
|
||||
controls features integration. *Verification Checkpoint*: Ensure the react
|
||||
google maps platform: advanced 3d mapping, routing, and custom controls
|
||||
elements are rendered correctly, and verify event callbacks handle data
|
||||
safely.
|
||||
- [ ] **Step 2.12: Feature Layer - React Google Maps: Advanced Marker
|
||||
Clustering and State Management** Read
|
||||
[references/example_marker.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_marker.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react google maps: advanced marker clustering and state management features
|
||||
integration. *Verification Checkpoint*: Ensure the react google maps:
|
||||
advanced marker clustering and state management elements are rendered
|
||||
correctly, and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.13: Feature Layer - Google Maps Platform React Marker and
|
||||
InfoWindow Usage Patterns** Read
|
||||
[references/example_markers.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_markers.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
google maps platform react marker and infowindow usage patterns features
|
||||
integration. *Verification Checkpoint*: Ensure the google maps platform
|
||||
react marker and infowindow usage patterns elements are rendered correctly,
|
||||
and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.14: Feature Layer - Multi-Map View Synchronization using React
|
||||
Camera State** Read
|
||||
[references/example_multiple.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_multiple.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
multi-map view synchronization using react camera state features
|
||||
integration. *Verification Checkpoint*: Ensure the multi-map view
|
||||
synchronization using react camera state elements are rendered correctly,
|
||||
and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.15: Feature Layer - React Google Maps: Core Initialization and
|
||||
Declarative Map Rendering in Next.js** Read
|
||||
[references/example_nextjs.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_nextjs.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react google maps: core initialization and declarative map rendering in
|
||||
next.js features integration. *Verification Checkpoint*: Ensure the react
|
||||
google maps: core initialization and declarative map rendering in next.js
|
||||
elements are rendered correctly, and verify event callbacks handle data
|
||||
safely.
|
||||
- [ ] **Step 2.16: Feature Layer - React Wrapper Patterns for Google Maps
|
||||
Platform UI Kit (3D & Places)** Read
|
||||
[references/example_places.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_places.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react wrapper patterns for google maps platform ui kit (3d & places)
|
||||
features integration. *Verification Checkpoint*: Ensure the react wrapper
|
||||
patterns for google maps platform ui kit (3d & places) elements are rendered
|
||||
correctly, and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.17: Feature Layer - React Wrapper Pattern for Google Maps
|
||||
Platform API Initialization** Read
|
||||
[references/example_react.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_react.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react wrapper pattern for google maps platform api initialization features
|
||||
integration. *Verification Checkpoint*: Ensure the react wrapper pattern for
|
||||
google maps platform api initialization elements are rendered correctly, and
|
||||
verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.18: Feature Layer - React/Remix Integration and Modern Google
|
||||
Maps Platform SDK Patterns** Read
|
||||
[references/example_remix.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_remix.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react/remix integration and modern google maps platform sdk patterns
|
||||
features integration. *Verification Checkpoint*: Ensure the react/remix
|
||||
integration and modern google maps platform sdk patterns elements are
|
||||
rendered correctly, and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.19: Feature Layer - Google Maps Platform Routes API Integration
|
||||
Patterns in React** Read
|
||||
[references/example_routes.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_routes.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
google maps platform routes api integration patterns in react features
|
||||
integration. *Verification Checkpoint*: Ensure the google maps platform
|
||||
routes api integration patterns in react elements are rendered correctly,
|
||||
and verify event callbacks handle data safely.
|
||||
- [ ] **Step 2.20: Feature Layer - React Integration of Google Maps Static API
|
||||
via Componentized URL Generation** Read
|
||||
[references/example_static.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_static.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react integration of google maps static api via componentized url generation
|
||||
features integration. *Verification Checkpoint*: Ensure the react
|
||||
integration of google maps static api via componentized url generation
|
||||
elements are rendered correctly, and verify event callbacks handle data
|
||||
safely.
|
||||
- [ ] **Step 2.21: Feature Layer - Patterns for External Library Integration
|
||||
and Deferred Map Initialization using React Hooks** Read
|
||||
[references/example_terra.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_terra.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
patterns for external library integration and deferred map initialization
|
||||
using react hooks features integration. *Verification Checkpoint*: Ensure
|
||||
the patterns for external library integration and deferred map
|
||||
initialization using react hooks elements are rendered correctly, and verify
|
||||
event callbacks handle data safely.
|
||||
- [ ] **Step 2.22: Feature Layer - React Web Worker Marker Clustering using
|
||||
`useSuperclusterWorker` and Advanced Markers** Read
|
||||
[references/example_worker.md](https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/references/example_worker.md).
|
||||
*Trigger Condition*: Triggered when the user application requires specific
|
||||
react web worker marker clustering using `usesuperclusterworker` and
|
||||
advanced markers features integration. *Verification Checkpoint*: Ensure the
|
||||
react web worker marker clustering using `usesuperclusterworker` and
|
||||
advanced markers elements are rendered correctly, and verify event callbacks
|
||||
handle data safely.
|
||||
@@ -0,0 +1,222 @@
|
||||
[
|
||||
{
|
||||
"name": "address-validation-api-web-api",
|
||||
"description": "Use this skill when developing an application that needs to verify the deliverability and accuracy of a postal address, standardize input addresses, obtain precise geolocation, or retrieve postal-specific metadata like CASS certification and delivery point details.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/address-validation-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "aerial-view-api-web-api",
|
||||
"description": "Use this skill when developers need to retrieve a URL for a pre-rendered, photorealistic 3D flyover video of a specified location, or when they require metadata about available aerial views, using the Aerial View API for Google Maps Platform.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/aerial-view-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "air-quality-api-web-api",
|
||||
"description": "Use this skill when the developer needs to retrieve current, historical (30-day), or forecasted (4-day) air quality data, including the Air Quality Index (AQI), pollutant concentrations, health recommendations, and corresponding visual heatmap tiles for any geographic location via the Google Maps Platform Air Quality API.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/air-quality-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "compliance-review",
|
||||
"description": "Use this skill to review implementation plans or code that uses Google Maps Platform in order to identify revisions that need to be made based on Google Maps Platform Terms of Service, Acceptable Use Policy, EEA terms, AI terms, and deprecation policies. Use this skill each time there is a significant revision to an implementation plan or code such as a change in the Google Maps Platform products selected or the handling of responses from Google Maps Platform.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/compliance-review/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "consumer-sdk-for-android-android",
|
||||
"description": "Use this skill when a developer needs to integrate a real-time, customizable map into their Android application to track a trip managed by Fleet Engine, allowing them to display the vehicle location, route polyline, and customize map features and styles for a consumer-facing experience.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/consumer-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "consumer-sdk-for-ios-ios",
|
||||
"description": "Use this skill when building native iOS applications that require real-time, customizable trip progress tracking for consumers using Fleet Engine data, including vehicle location, route visualization, and dynamic map styling customizations.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/consumer-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "directions-api-web-api",
|
||||
"description": "Use this skill when developers need to calculate routes, distance, travel time, and turn-by-turn instructions between two or more geographic points using the Directions API. It supports various travel modes (driving, walking, transit) and optional constraints like avoidance features and optimizing waypoints.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/directions-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "distance-matrix-api-web-api",
|
||||
"description": "Use this skill when calculating travel distance and time between multiple origins and destinations simultaneously. This capability is fulfilled by the modern Routes API, which replaces the deprecated Distance Matrix API. Users can request a matrix of routes and specify travel modes like driving, transit, or walking.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/distance-matrix-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "driver-sdk-for-android-android",
|
||||
"description": "Use this skill when implementing a driver application on Android that needs to report real-time vehicle location, route updates, estimated time of arrival (ETA), remaining distance, and waypoint status to the Fleet Engine backend service for efficient fleet management.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/driver-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "driver-sdk-for-ios-ios",
|
||||
"description": "Use this skill when developing iOS driver applications that need to communicate real-time vehicle location, route, ETA, and trip progress updates to the Fleet Engine backend service for efficient fleet management and consumer visibility.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/driver-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "elevation-api-web-api",
|
||||
"description": "Use this skill to retrieve the precise ground elevation (in meters) for specific latitude/longitude coordinates, or to sample elevation data along a defined path for topographical analysis and terrain visualization.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/elevation-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "fleet-engine-service-web-api",
|
||||
"description": "Use this skill when integrating the Fleet Engine Service via the Web API to manage the entire lifecycle of on-demand transportation vehicles and trips, including provisioning new vehicles, updating vehicle attributes, creating and tracking trip progress, and reporting billable usage metrics.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/fleet-engine-service-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "geocoding-api-web-api",
|
||||
"description": "Use this skill when a developer needs to convert addresses into geographic coordinates (geocoding) or convert coordinates back into human-readable addresses (reverse geocoding). It provides precise location data, supports restricting results by region or type, and returns details about building entrances, outlines, and landmark proximity.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/geocoding-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "geolocation-api-web-api",
|
||||
"description": "Use this skill when needing to determine the physical location (latitude and longitude) of a client device based on surrounding cell tower and Wi-Fi access point data, often serving as a reliable fallback when GPS or HTML5 geolocation is unavailable or slow.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/geolocation-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "gmp-common-api-keys",
|
||||
"description": "Manage Google Maps Platform API keys, including generation, security, and required authentication mechanisms.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-common-api-keys/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "gmp-common-grounding",
|
||||
"description": "Use this skill to access rules and specifications for the Google Maps Platform Grounding Lite service and its Model Context Protocol (MCP) tools for AI applications.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-common-grounding/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "gmp-common-pricing",
|
||||
"description": "Provide comprehensive guidelines on Google Maps Platform pricing, including subscription tiers, pay-as-you-go model, free usage caps, volume discounts, and detailed CPM pricing tables.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-common-pricing/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "gmp-framework-flutter",
|
||||
"description": "This skill provides the architectural foundation and implementation best practices for developing Google Maps Platform applications using Flutter and the `google_maps_flutter` package. It focuses on the declarative nature of the Flutter SDK, managing map state and objects through widget properties, and handling asynchronous operations via `GoogleMapController`.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-flutter/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "gmp-framework-react",
|
||||
"description": "This guide serves as the mandatory entry point and architectural router for building Google Maps Platform applications using the **React** framework, specifically leveraging the official `@vis.gl/react-google-maps` SDK and modern API features. The content is rigorously curated to enforce the **Absolute Universal Zero-Legacy Mandate**, ensuring all generated solutions utilize current, future-proof, cost-optimized, and performant APIs. Developers are proactively guided toward modern replacements like `AdvancedMarkerElement`, the `Place` class (Places API New), and the `Route` class (Routes API), strictly forbidding any reliance on deprecated or disabled callback-based services. This router ensures comprehensive coverage of React patterns, from basic initialization to complex data visualization and clustering techniques.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/gmp-framework-react/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "javascript-consumer-sdk-javascript",
|
||||
"description": "Use this skill when integrating the Fleet Engine trip tracking experience into a JavaScript web application, allowing the user to display real-time route updates, customize map styling (polylines, markers, features), and visualize vehicle or shipment progress accurately for the end consumer.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/javascript-consumer-sdk-javascript/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "map-tiles-api-web-api",
|
||||
"description": "Accesses the Map Tiles API for low-level image retrieval. Use this skill to fetch 2D raster tiles (roadmap, satellite, terrain) using XYZ coordinates, retrieve URIs for photorealistic 3D tiles (OGC format), and obtain Street View metadata. Always trigger for queries requiring direct tile access, custom map rendering in third-party engines, XYZ coordinates, or applying custom map styling using a Map ID.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/map-tiles-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-3d-sdk-for-android-android",
|
||||
"description": "Use this skill when developing native Android applications that require highly immersive and interactive photorealistic 3D map experiences. Developers can integrate advanced features like custom 3D markers, complex 3D models, polylines, and polygons, along with fine-grained control over camera movements, animations, and user interactions within the 3D environment.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-3d-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-3d-sdk-for-ios-ios",
|
||||
"description": "Use this skill to integrate interactive, photorealistic 3D maps into an iOS application using the Maps 3D SDK. Developers can add custom 3D models, markers, polylines, and polygons, and precisely control the camera movement and available user controls for creating immersive geographic experiences.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-3d-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-datasets-api-web-api",
|
||||
"description": "Use this skill when managing custom geospatial datasets for visualization and styling on Google Maps Platform. Developers can create new datasets, upload data in CSV, GeoJSON, or KML formats, and retrieve metadata or content for existing datasets using the Maps Datasets API.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-datasets-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-embed-api-javascript",
|
||||
"description": "Uses the Maps Embed API to display interactive maps, street view panoramas, directions, or place searches directly on a web page using simple HTML <iframe> tags and URL parameters. This approach requires minimal coding and is ideal for quick, static embedding.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-embed-api-javascript/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-javascript-api-javascript",
|
||||
"description": "Developers use the Maps JavaScript API skill to embed customizable, interactive 2D and photorealistic 3D maps into web applications. This enables advanced location intelligence features, including real-time route calculation, dynamic geospatial data visualization (GeoJSON, KML), Address Validation, and comprehensive search capabilities powered by the Places API and Street View service.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-javascript-api-javascript/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-sdk-for-android-android",
|
||||
"description": "Use this skill when building mobile applications for Android that require interactive maps, custom styling, location data visualization using markers, heatmaps, or overlays, and integration of Google Street View experiences. It guides the developer in configuring map components, handling gestures, managing the map camera, and responding to user interactions with map features using the native Maps SDK for Android.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-sdk-for-ios-ios",
|
||||
"description": "Use this skill when developing native iOS applications in Swift or Objective-C to embed interactive maps, customize visual appearance using cloud styling, manage map elements like markers and layers, and integrate advanced features like Street View and reverse geocoding.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "maps-static-api-web-api",
|
||||
"description": "Use this skill when the developer needs to generate and embed a high-resolution, non-interactive map image into a web page, email, or print document using a simple URL request. It allows for the customization of map location, zoom, map type, markers, paths, and cloud-based styling via a `mapId`.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/maps-static-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "navigation-sdk-for-android-android",
|
||||
"description": "Use this skill to integrate interactive, turn-by-turn navigation and rich map experiences into a native Android application. It covers map display customization, marker management, route modification, real-time navigation updates (location, speed, time), UI control, and adding support for Android Auto navigation.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/navigation-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "navigation-sdk-for-ios-ios",
|
||||
"description": "Use this skill when building feature-rich, real-time, turn-by-turn navigation experiences for iOS applications, including CarPlay support. Developers can customize routing preferences, manage location and route updates, configure the navigation UI, and implement in-app traffic incident reporting and handling.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/navigation-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "places-aggregate-api-web-api",
|
||||
"description": "Use this skill when defining SQL queries to analyze Google Maps Platform Places Aggregate data in BigQuery. It enables users to efficiently calculate the number of places or retrieve their identifiers within a defined geographic area, filtering the results by criteria such as place type, operating status, price level, and user ratings.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/places-aggregate-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "places-api-web-api",
|
||||
"description": "Use this skill when a user needs to integrate location search, retrieve place details, offer autocomplete suggestions, fetch photos, and utilize advanced features like AI-powered summaries or route-based searching for points of interest via the Google Maps Platform Places API (New).",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/places-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "places-insights-api-web-api",
|
||||
"description": "Use this skill when defining SQL queries against the Google Maps Platform BigQuery datasets to retrieve aggregated place counts, perform geographic analysis using H3 indexing, and filter place analytics by brand identifiers and type categories.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/places-insights-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "places-sdk-for-android-android",
|
||||
"description": "Use this skill when developing native Android applications that require modern place-based functionality, including searching for places, fetching detailed information, retrieving photos, implementing the Autocomplete widget, and displaying AI-powered place summaries using the Places SDK for Android (New).",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/places-sdk-for-android-android/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "places-sdk-for-ios-ios",
|
||||
"description": "Use this skill when implementing native iOS features for finding places, providing real-time autocomplete suggestions, fetching detailed information (including photos, ratings, and AI summaries) for specific locations, or determining the user's current place.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/places-sdk-for-ios-ios/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "pollen-api-web-api",
|
||||
"description": "Use this skill when a user seeks detailed environmental information regarding pollen across five-day forecasts. The skill provides access to Pollen Index data, specific plant sources, health recommendations, textual descriptions of pollen conditions, cross-reaction details, and visual heatmap tiles for specific geographic locations. Developers utilize the Pollen API endpoints to integrate this data into environmental or health applications.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/pollen-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "roads-api-web-api",
|
||||
"description": "Use this skill when a developer needs to accurately align GPS data to road networks (Snap to Roads), find the closest road segment to a location (Nearest Road), or retrieve the speed limit for specific road segments using latitude/longitude coordinates or Google Place IDs.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/roads-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "route-optimization-api-web-api",
|
||||
"description": "Use this skill to solve complex Vehicle Routing Problems (VRP) by optimizing multi-stop delivery plans. It computes efficient routes considering dynamic constraints like time windows, vehicle capacities, costs, real-time traffic, and specialized objectives, returning detailed itineraries, costs, and navigation tokens. Authentication requires Application Default Credentials (ADC).",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/route-optimization-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "routes-api-web-api",
|
||||
"description": "Use this skill when a user needs to compute efficient routes, retrieve travel distances and times, and specify detailed parameters like eco-friendly options, intermediate stops, and features to avoid (e.g., tolls or highways) for single or matrix route requests.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/routes-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "solar-api-web-api",
|
||||
"description": "Use this skill to calculate the solar potential and estimated financial benefits for a specified building location. It provides details like optimal panel placement, annual sunshine hours, and carbon offset, along with access to detailed geospatial data including roof geometry and GeoTIFFs for aerial imagery, elevation, and solar irradiance flux.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/solar-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "street-view-static-api-web-api",
|
||||
"description": "Use this skill when a developer needs to generate a URL that returns a high-quality, non-interactive static image of a Street View panorama, controlling parameters like location, size, and camera orientation (heading, pitch, and field of view).",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/street-view-static-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "time-zone-api-web-api",
|
||||
"description": "Use this skill when a developer needs to retrieve the accurate time zone and associated UTC and Daylight Savings offsets for a specific geographic coordinate and moment in time using the Time Zone API.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/time-zone-api-web-api/SKILL.md"
|
||||
},
|
||||
{
|
||||
"name": "weather-api-web-api",
|
||||
"description": "Always invoke this skill when requesting current, forecasted, or historical meteorological data. This skill facilitates retrieval of precise weather information\u2014including temperature, cloud cover, precipitation, severe weather alerts, and 24-hour historical conditions\u2014for any given latitude and longitude coordinates via the Google Maps Platform Weather API. Use whenever a request mentions global atmospheric data, weather conditions, or local forecasts.",
|
||||
"entrypoint": "https://www.gstatic.com/googlemapsplatform-agent-skills/weather-api-web-api/SKILL.md"
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,221 @@
|
||||
# 🛠️ PinPoint Golf — Developer & Architecture Guide
|
||||
|
||||
This guide is written for engineers looking to maintain, customize, or extend **PinPoint Golf**. It explains the full-stack architecture, user authentication flows, OAuth/SSO integration, database persistence, community benchmark mathematics, and outlines the prioritized roadmap of future features.
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Directory Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── server.ts # Full-stack Express server (API endpoints + Vite middleware / static files)
|
||||
├── Dockerfile # Multi-stage production container build (Node.js -> Node.js Runner)
|
||||
├── docker-compose.yaml # Compose file with persistent volume and OAuth environment vars
|
||||
├── .env.example # Template for Google Maps, JWT, Google OAuth, and Authentik OIDC
|
||||
├── index.html # HTML entry point with Plus Jakarta Sans & JetBrains Mono
|
||||
├── package.json # Dependencies, esbuild scripts, and npm scripts
|
||||
├── vite.config.ts # Vite 8 configuration with Tailwind v4 & path aliases
|
||||
├── data/
|
||||
│ └── pinpoint_db.json # Atomic JSON persistent database (users, rounds, settings, courses)
|
||||
├── src/
|
||||
│ ├── main.tsx # React root mount point
|
||||
│ ├── App.tsx # Root coordinator, global state, persistence, sync & modal hubs
|
||||
│ ├── index.css # Tailwind CSS v4 setup & custom scrollbars
|
||||
│ ├── context/
|
||||
│ │ └── AuthContext.tsx # User session state, JWT tokens, login, register, and SSO hooks
|
||||
│ ├── types/
|
||||
│ │ ├── golf.ts # Core golf interfaces (Hole, Course, Round, Stats, GPS)
|
||||
│ │ └── auth.ts # User profiles, PublicGolferStats, CommunityComparison schemas
|
||||
│ ├── data/
|
||||
│ │ ├── courses.ts # Default course presets (Pebble Cove, Pine Valley, St Andrews)
|
||||
│ │ └── sampleRounds.ts # Preloaded 18-hole demonstration round with full stats
|
||||
│ ├── services/
|
||||
│ │ ├── api.ts # Full client HTTP client with Bearer auth headers
|
||||
│ │ ├── golfApi.ts # OpenGolfAPI client for searching 32,000+ public courses
|
||||
│ │ └── weatherService.ts # Open-Meteo Open Weather API client & GPS geolocation service
|
||||
│ ├── server/
|
||||
│ │ ├── db.ts # Database manager (atomic file writes, seed golfers, user scoping)
|
||||
│ │ ├── authMiddleware.ts # JWT verification & request user injection
|
||||
│ │ └── routes/
|
||||
│ │ ├── authRoutes.ts # Local register/login, user profile, settings, and SSO endpoints
|
||||
│ │ ├── roundsRoutes.ts # Scoped rounds CRUD, sync-batch, and custom course endpoints
|
||||
│ │ └── communityRoutes.ts # Global public leaderboard and comparative benchmark engine
|
||||
│ ├── utils/
|
||||
│ │ ├── golfStats.ts # Pure statistical calculation engine & scoring formulas
|
||||
│ │ ├── geoUtils.ts # Haversine distance, bearings, green edges & hole auto-detect
|
||||
│ │ └── achievements.ts # Automated trophy & achievements evaluator (23 achievements)
|
||||
│ └── components/
|
||||
│ ├── Navbar.tsx # 3-Zone top bar + user avatar profile dropdown + mobile tab bar
|
||||
│ ├── AuthModal.tsx # Tabbed modal: Local Auth (Email/Pass), Register, and SSO/Authentik
|
||||
│ ├── CommunityComparisonModal.tsx # Head-to-head comparison cards & global public leaderboard
|
||||
│ ├── HoleEntry.tsx # Tactile on-course scoring UI with large touch hitboxes
|
||||
│ ├── GolfGpsRangefinder.tsx # Google Maps satellite map + Front/Mid/Back digital HUD
|
||||
│ ├── Scorecard.tsx # Paper-style 18-hole digital scorecard with Out/In splits
|
||||
│ ├── Analytics.tsx # Performance dashboard (FIR radar, GIR misses, bunker saves)
|
||||
│ ├── Settings.tsx # Granular stat switchboard & 1-click tracking presets
|
||||
│ ├── NewRoundModal.tsx # Start round dialog with OpenGolfAPI search & tees
|
||||
│ ├── CourseEditorModal.tsx # Builder to create new courses or duplicate existing ones
|
||||
│ ├── RoundsHistoryModal.tsx # Round history, round switching, and CSV/text export
|
||||
│ └── AchievementsModal.tsx # Trophy cabinet with tier filters and progress tracking
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Authentication & Identity Architecture
|
||||
|
||||
PinPoint Golf supports dual-mode authentication:
|
||||
1. **Local Authentication**:
|
||||
- Passwords hashed with `bcryptjs` (salt rounds: 10).
|
||||
- Generates signed JWTs (30-day expiry) containing user `id`, `email`, and `username`.
|
||||
- Passwords and sensitive fields are stripped before transmitting user objects.
|
||||
2. **OAuth & SSO Providers**:
|
||||
- **Google OAuth 2.0**: Compatible with Google Identity Services. Configurable via `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`.
|
||||
- **Authentik (OIDC)**: Designed for homelab setups, enterprise Identity Providers, and Keycloak. Configurable via `AUTHENTIK_ISSUER_URL`, `AUTHENTIK_CLIENT_ID`, and `AUTHENTIK_CLIENT_SECRET`.
|
||||
- **GitHub OAuth**: Configurable via `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET`.
|
||||
- **Zero-Friction Test Mode**: If external secrets are not yet provisioned in `.env`, the modal allows one-click instant testing using authentic golfer SSO credentials.
|
||||
|
||||
### API Auth Endpoints:
|
||||
| Endpoint | Method | Auth Required | Description |
|
||||
|---|---|---|---|
|
||||
| `/api/auth/register` | POST | No | Create local account with email, username, password, handicap |
|
||||
| `/api/auth/login` | POST | No | Authenticate with email/username + password |
|
||||
| `/api/auth/me` | GET | Bearer | Retrieve profile and synchronized tracking settings |
|
||||
| `/api/auth/profile` | PUT | Bearer | Update name, handicap index, home club, avatar, or `isPublic` flag |
|
||||
| `/api/auth/settings` | GET/PUT | Bearer | Read or persist user tracking switchboard preferences |
|
||||
| `/api/auth/oauth/providers` | GET | No | Discovery endpoint for configured OAuth options |
|
||||
| `/api/auth/oauth/sso-login` | POST | No | Ingest OAuth token / SSO identity and return JWT |
|
||||
|
||||
---
|
||||
|
||||
## 3. Persistent Database (`data/pinpoint_db.json`)
|
||||
|
||||
The datastore in `src/server/db.ts` uses atomic temp-file write-and-rename semantics (`fs.renameSync`) to ensure durability against crashes. In Docker, it mounts to the named volume `pinpoint_data:/app/data`.
|
||||
|
||||
### Database Schema:
|
||||
```typescript
|
||||
interface DatabaseSchema {
|
||||
users: Array<User & { passwordHash?: string }>;
|
||||
settings: Record<string, TrackingSettings>; // userId -> settings
|
||||
rounds: Record<string, Round[]>; // userId -> rounds
|
||||
customCourses: Record<string, Course[]>; // userId -> courses
|
||||
publicSeedGolfers: PublicGolferStats[]; // Seed public players for comparison
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Community Benchmark & Public Comparison Engine
|
||||
|
||||
Users can toggle their profile between **Public** (default) and **Private** via the profile dropdown or the Community modal:
|
||||
- When `isPublic: true`, their rounds automatically compute into the global database metrics:
|
||||
- **Scoring Average** (18-hole stroke average)
|
||||
- **FIR %** (Fairways in Regulation)
|
||||
- **GIR %** (Greens in Regulation)
|
||||
- **Putts Per Round** (PPR)
|
||||
- **Scramble %** (Up & Down par saves after missing GIR)
|
||||
- **Sand Save %** (Greenside bunker par conversion)
|
||||
- **Penalties Per Round** (Strokes lost to hazards/OB)
|
||||
- The `/api/community/compare` endpoint calculates:
|
||||
- Global community averages across all public scorecards.
|
||||
- Handicap bracket averages: **Scratch (< 5.0)**, **Mid-Cap (5.0–15.0)**, and **High-Cap (> 15.0)**.
|
||||
- Percentile rankings (0–100%) for each metric.
|
||||
- Automated coaching insights identifying the golfer's highest-leverage scoring improvements.
|
||||
|
||||
---
|
||||
|
||||
## 5. Real-Time Weather Forecast Integration (Open Weather API)
|
||||
|
||||
Before teeing off, golfers evaluate live environmental conditions—temperature, wind velocity, heading, and precipitation probability—to select appropriate clubs, prepare rain equipment, and adjust for air density effects on ball carry.
|
||||
|
||||
### Architecture & Service Design (`src/services/weatherService.ts`)
|
||||
- **Open Weather API Provider**: Uses the **Open-Meteo Meteorological API** (`https://api.open-meteo.com/v1/forecast`), an open-source, non-commercial and commercial-ready meteorological service operated with Open Database License (ODbL).
|
||||
- **Zero API-Key Requirement**: Works out-of-the-box with zero paid subscription dependencies or API keys.
|
||||
- **Dual Geolocation Resolution Strategy**:
|
||||
1. **Active Device GPS Position**: Calls browser `navigator.geolocation.getCurrentPosition` with `enableHighAccuracy: true` to get the player's immediate physical coordinates.
|
||||
2. **Course Location Fallback**: If the player is planning a round indoors, has GPS disabled, or denies browser permission, the system automatically falls back to coordinates from the selected course's Hole 1 tee box or course center (e.g. Pebble Beach, Pine Valley, St Andrews).
|
||||
3. **Seamless Toggle**: Users can switch between `[ 📍 My GPS ]` and `[ ⛳ Course GPS ]` at any time with a 1-click refresh button.
|
||||
|
||||
### Real-Time Weather Metrics Captured
|
||||
| Metric | Unit | API Parameter | Relevance to Golf |
|
||||
|---|---|---|---|
|
||||
| **Current Temperature** | °C (with °F converted) | `current.temperature_2m` | Ball compression and air density (colder air = less carry distance) |
|
||||
| **Feels-Like Temperature** | °C | `current.apparent_temperature` | Golfer comfort, layering, and thermal prep |
|
||||
| **Wind Speed** | km/h | `current.wind_speed_10m` | Club adjustment (rule of thumb: ~1 club per 15 km/h wind) |
|
||||
| **Wind Direction & Heading** | 16-point Compass (`SW`) + Degrees (0–360°) | `current.wind_direction_10m` | Aim lines and shot shaping (crosswind, headwind, tailwind) |
|
||||
| **Wind Intensity Category** | `Calm` / `Breezy` / `Strong` / `Gusty` | Auto-classified based on speed | Stored directly in Round summary for fast filtering |
|
||||
| **Precipitation Probability** | % (0–100) | `hourly.precipitation_probability` | Rain risk assessment and green speed (wet greens roll slower) |
|
||||
| **Weather Condition & WMO Code** | WMO 4677 code | `current.weather_code` | Mapped to UI icons (`Sun`, `CloudSun`, `CloudRain`, `CloudLightning`) |
|
||||
| **Relative Humidity** | % | `current.relative_humidity_2m` | Atmospheric moisture and swing comfort |
|
||||
|
||||
### Scorecard Persistence & Data Flow
|
||||
When a player starts a new round via `NewRoundModal`:
|
||||
1. The forecast is automatically preloaded via GPS.
|
||||
2. The user can review the live weather card and make optional manual adjustments or notes.
|
||||
3. Upon clicking **Tee Off**, the full `WeatherForecast` object is attached to `round.weatherData`, and summary strings (`weather` and `wind`) are recorded.
|
||||
4. Saved rounds in `Scorecard`, `RoundsHistoryModal`, and exports reflect the actual weather encountered during the round.
|
||||
|
||||
---
|
||||
|
||||
## 6. How-To Guides for Extending the App
|
||||
|
||||
### Guide A: Adding a New Tracked Stat (e.g. Approach Shot Club)
|
||||
1. **Update Data Types (`src/types/golf.ts`)**: Add `approachClub?: string` to `HoleScore` and `trackApproachClub: boolean` to `TrackingSettings`.
|
||||
2. **Update Settings Defaults (`src/data/courses.ts`)**: Set default in `DEFAULT_TRACKING_SETTINGS`.
|
||||
3. **Add Toggle Switch (`src/components/Settings.tsx`)**: Add toggle under the relevant category.
|
||||
4. **Add Input Controls (`src/components/HoleEntry.tsx`)**: Render input when `settings.trackApproachClub` is active.
|
||||
5. **Scorecard & Analytics (`Scorecard.tsx` & `golfStats.ts`)**: Display row in scorecard and aggregate in round stats.
|
||||
|
||||
### Guide B: Adding a New Achievement / Trophy
|
||||
1. Add definition in `ALL_ACHIEVEMENTS` in `src/utils/achievements.ts`.
|
||||
2. Add rule evaluation in `evaluateAchievements()`.
|
||||
3. Add icon mapping in `src/components/AchievementsModal.tsx`.
|
||||
|
||||
---
|
||||
|
||||
## 7. 🎯 Wanted / Future Features Roadmap
|
||||
|
||||
Here is the prioritized list of planned future enhancements:
|
||||
|
||||
### High Priority
|
||||
1. **Strokes Gained (SG) Analytics Engine**:
|
||||
- Compare shot performance against PGA Tour and scratch golfer baselines.
|
||||
- Categorize strokes gained into **SG: Off-the-Tee (OTT)**, **SG: Approach**, **SG: Around-the-Green (ARG)**, and **SG: Putting**.
|
||||
2. **Official World Handicap System (WHS) Calculator**:
|
||||
- Automatic calculation of **Handicap Differential**: $\text{Differential} = \frac{113}{\text{Slope}} \times (\text{Adjusted Gross Score} - \text{Course Rating} - \text{PCC})$.
|
||||
- Automatic 20-round scoring record maintaining the official index from the best 8 differentials.
|
||||
3. **Multiplayer Live Round & Foursome Matchplay**:
|
||||
- Real-time WebSockets synchronization so groups playing together can view live leaderboards, skins, match play, and stableford games hole-by-hole.
|
||||
|
||||
### Medium Priority
|
||||
4. **AI Caddie & Shot Recommender**:
|
||||
- Based on user's logged club distances, live GPS yardage to the pin, wind speed, and elevation, automatically suggest club selection and target aim line.
|
||||
5. **Ball Flight & Dispersion Heatmaps on GPS Green Overlays**:
|
||||
- Plot approach shot landing spots visually on the satellite map green to detect left/right slice or pull patterns.
|
||||
6. **Progressive Web App (PWA) Offline Service Worker**:
|
||||
- Installable on iOS/Android home screens with offline caching for courses with no cellular reception.
|
||||
7. **Apple Watch / Wear OS Companion App**:
|
||||
- High-contrast wrist interface showing Bushnell-style Front/Center/Back yardages and quick 1-tap score increments.
|
||||
|
||||
### Community & Social
|
||||
8. **Golf Society & Tournament Director Mode**:
|
||||
- Flight divisions (A/B/C), net and gross prizes, two-man scrambles, and printable tournament scorecards.
|
||||
9. **Course Review & Condition Notes**:
|
||||
- Golfer feedback on green speeds (Stimp reading), aerated greens notices, and course ratings.
|
||||
|
||||
---
|
||||
|
||||
## 8. Scripts Reference
|
||||
|
||||
```bash
|
||||
# Start full-stack development server on port 3000
|
||||
npm run dev
|
||||
|
||||
# Run TypeScript typechecks
|
||||
npm run lint
|
||||
|
||||
# Build production bundle (client + server.cjs)
|
||||
npm run build
|
||||
|
||||
# Clean temporary files
|
||||
npm run clean
|
||||
```
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# ========================================================
|
||||
# Stage 1: Build the React + Vite frontend and Express server
|
||||
# ========================================================
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies first (layer caching)
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
# Copy application source code
|
||||
COPY . .
|
||||
|
||||
# Optional Build Argument for Google Maps API Key
|
||||
ARG VITE_GOOGLE_MAPS_API_KEY=""
|
||||
ENV VITE_GOOGLE_MAPS_API_KEY=$VITE_GOOGLE_MAPS_API_KEY
|
||||
|
||||
# Build production bundle (Vite client + server.cjs)
|
||||
RUN npm run build
|
||||
|
||||
# ========================================================
|
||||
# Stage 2: Serve full-stack application (API + SPA) with Node.js
|
||||
# ========================================================
|
||||
FROM node:22-alpine AS runner
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=3000
|
||||
|
||||
# Install production dependencies only
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev && npm cache clean --force
|
||||
|
||||
# Copy built bundle from builder stage
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
# Create persistent database directory
|
||||
RUN mkdir -p /app/data
|
||||
VOLUME ["/app/data"]
|
||||
|
||||
# Expose HTTP application port
|
||||
EXPOSE 3000
|
||||
|
||||
# Container healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget --quiet --tries=1 --spider http://localhost:3000/healthz || exit 1
|
||||
|
||||
# Start production full-stack server
|
||||
CMD ["node", "dist/server.cjs"]
|
||||
@@ -0,0 +1,115 @@
|
||||
# ⛳ PinPoint Golf: Smart Scorecard, GPS Rangefinder & Shot Tracker
|
||||
|
||||
A modern, tournament-grade golf scorecard and performance tracking web application built with **React 19**, **TypeScript**, **Tailwind CSS v4**, **Node.js Express**, and **Google Maps Platform**.
|
||||
|
||||
PinPoint Golf goes far beyond basic stroke scoring, providing granular stats tracking, GPS-assisted green rangefinding, worldwide course discovery via public APIs, custom course building, career achievements, and **user authentication with community benchmark analytics**.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Features Overview
|
||||
|
||||
### 1. 👤 User Authentication & Cloud Synchronization
|
||||
- **Local Authentication**: Register and log in with email, username, and secure password hashing (`bcryptjs`).
|
||||
- **OAuth & Single Sign-On (SSO)**:
|
||||
- **Google Login**: Google OAuth 2.0 integration.
|
||||
- **Authentik (OIDC)**: Direct support for homelab setups, enterprise IdPs, and Keycloak via OIDC.
|
||||
- **GitHub**: One-click developer OAuth sign in.
|
||||
- **Persistent Cloud Database**: All rounds, customized tracking settings, and custom courses are saved in a durable database (`pinpoint_db.json`) tied to the golfer's user account.
|
||||
- **Offline-First & Auto-Sync**: Continue playing offline seamlessly; when connected or logging in, offline rounds automatically sync to your cloud account.
|
||||
|
||||
### 2. 🌍 Community Benchmark & Public Leaderboard
|
||||
- **Public vs. Private Privacy Control**: Easily toggle your scorecard visibility in your profile.
|
||||
- **Head-to-Head Comparison Matrix**: Compare your **Scoring Average**, **FIR %**, **GIR %**, **Putts Per Round**, **Scrambling %**, **Sand Saves**, and **Penalty Strokes** against:
|
||||
- Global Community Averages across thousands of recorded rounds.
|
||||
- Your specific Handicap Bracket (**Scratch < 5**, **Mid-Cap 5–15**, **High-Cap > 15**).
|
||||
- Calculated **Percentile Rankings (0–100%)** with automated coaching insights highlighting your best scoring leverage areas.
|
||||
- **Public Golfers Leaderboard**: Global leaderboard filtered by handicap division, displaying verified rounds, lowest gross scores, and key stats.
|
||||
|
||||
### 3. 📊 Deep Stats & Scoring Engine
|
||||
- **Customizable Stat Switchboard**: Turn any stat on or off anytime with instant UI adaptation.
|
||||
- **Fairway in Regulation (FIR)**: Center Hit, Miss Left ⬅, Miss Right ➡, Miss Short ⬇ (automatically marked N/A on Par 3s).
|
||||
- **Green in Regulation (GIR)**: Green Hit vs Missed, with approach miss direction dispersal (Short, Long, Left, Right).
|
||||
- **Official Putts (On Green)**: Fast touch steppers for putts taken on the putting green surface.
|
||||
- **Foregreen / Fringe Putts (Off Green)**: Tracks putter strokes taken from the collar/fringe separately from official green putts (in compliance with USGA/R&A rules).
|
||||
- **Bunker Encounters**: Distinguishes **Fairway Bunkers** from **Greenside Bunkers** and tracks **Sand Save** conversion.
|
||||
- **Detailed Penalties**: Categorizes penalty strokes into **Water/Hazard 💧**, **Out of Bounds (OB) ⚪**, **Lost Ball 🔍**, and **Unplayable Lie ⚠️**.
|
||||
- **Scrambling & Up & Down**: Automatically tracks par saves following missed greens in regulation.
|
||||
- **Metric by Default**: Distances in **Meters**, putts in **Meters**, temperatures in **Celsius**, and wind in **km/h** (toggleable to Imperial yards/feet in settings).
|
||||
|
||||
### 4. ⛅ Real-Time Weather Forecast (Open Weather API)
|
||||
- **Live GPS Meteorological Conditions**: Before starting a round, retrieves current conditions using the open-source Open-Meteo meteorological API based on the golfer's live GPS coordinates.
|
||||
- **Key Golf Weather Metrics**:
|
||||
- **Temperature**: Displayed in Celsius (°C) and Fahrenheit (°F) with apparent feels-like temperature.
|
||||
- **Wind Speed & Direction**: Real-time wind speed in **km/h**, 16-point directional compass heading (e.g. `SW 230°`), and automatic classification (`Calm`, `Breezy`, `Strong`, `Gusty`).
|
||||
- **Precipitation Probability**: Hourly rain probability percentage with a visual risk bar (low, moderate, high chance).
|
||||
- **Dual Source Mode**: Toggle between **My GPS** and **Course GPS** with a 1-click refresh.
|
||||
- **Round Snapshot**: Conditions are saved directly with the scorecard and displayed on the digital scorecard and history logs.
|
||||
|
||||
### 5. 🛰️ Google Maps Satellite & GPS Rangefinder
|
||||
- **Live GPS Green Distance**: Real-time high-accuracy distance to the **Front Edge**, **Center / Pin Flag**, and **Back Edge** of the green.
|
||||
- **High-Contrast Digital HUD**: Sunlight-legible Bushnell/Garmin-style readout.
|
||||
- **Automatic Hole Detection**: Detects which hole you are standing on based on GPS coordinates and fairway corridor proximity.
|
||||
- **Shot Distance Measurement**: Mark your ball's origin at the tee and walk to measure your exact drive distance in meters.
|
||||
- **Demo / Simulation Mode**: Built-in simulator allows testing green distances and hole transitions indoors or on desktop.
|
||||
|
||||
### 6. 🗺️ Worldwide Golf Course Directory & Custom Course Builder
|
||||
- **OpenGolfAPI Integration**: Search and import official scorecards and pars from a directory of **32,000+ golf courses worldwide**.
|
||||
- **Custom Course Creator**: Build any 9 or 18-hole course with custom Pars, Yardages/Meters, and Handicap indices.
|
||||
- **Duplicate & Modify**: 1-click clone and modify any existing or imported course to match your local club's current tee setup.
|
||||
|
||||
### 7. 🏆 Career Trophies & Achievements
|
||||
- **Automated Milestone Engine**: Automatically evaluates all played rounds to unlock 23 achievements across 4 tiers (Bronze, Silver, Gold, Platinum), from *Century Broken (Break 100)* to *Ace in the Hole (Hole-in-One)* and *Drove the Green*.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Quick Start (Local Development)
|
||||
|
||||
### 1. Clone & Install Dependencies
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd pinpoint-golf
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure Environment Variables
|
||||
Copy `.env.example` to `.env`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
Add your Google Maps API key and optional OAuth credentials:
|
||||
```env
|
||||
VITE_GOOGLE_MAPS_API_KEY="AIzaSy..."
|
||||
JWT_SECRET="your_secret_key"
|
||||
```
|
||||
|
||||
### 3. Start Full-Stack Dev Server
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
Open [http://localhost:3000](http://localhost:3000) in your browser.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
The application includes an optimized multi-stage `Dockerfile` and `docker-compose.yaml` configured with persistent volume storage for the database.
|
||||
|
||||
### Using Docker Compose (Recommended)
|
||||
|
||||
1. Build and launch the container:
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
2. Access the application at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
3. Verify health status:
|
||||
```bash
|
||||
curl http://localhost:3000/healthz
|
||||
# Output: healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📖 Further Development & Future Roadmap
|
||||
For complete architecture specifications and the future roadmap (Strokes Gained, WHS Handicap Calculator, Live Matchplay), see the [Developer Guide](DEVELOPER_GUIDE.md).
|
||||
@@ -0,0 +1,704 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "react-example",
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vis.gl/react-google-maps": "^1.10.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^8.3.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^7.0.2",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@google/genai": ["@google/genai@2.28.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-hyNAJj+ywu3L46qlZod6KO/JvcT3QONz6VCiPevC/FWOjAD+LQb7DMLUoV8vzoLpt61Q0ruQX99iqbxEjPJWmw=="],
|
||||
|
||||
"@googlemaps/js-api-loader": ["@googlemaps/js-api-loader@2.1.3", "", { "dependencies": { "@types/google.maps": "^3.65.3" } }, "sha512-1t6umwRufEGzIPjHPjPeuuY1dCal0zU2bZeomkf4OA0MprFdlKCnXY3zeFkzDaCvKjwbFubeapm61P5+EbrAoA=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.153.0", "", {}, "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw=="],
|
||||
|
||||
"@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
|
||||
|
||||
"@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
|
||||
|
||||
"@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
|
||||
|
||||
"@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
|
||||
|
||||
"@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
|
||||
|
||||
"@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
|
||||
|
||||
"@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
|
||||
|
||||
"@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
|
||||
|
||||
"@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
|
||||
|
||||
"@rolldown/binding-android-arm-eabi": ["@rolldown/binding-android-arm-eabi@1.2.13", "", { "os": "android", "cpu": "arm" }, "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA=="],
|
||||
|
||||
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.2.13", "", { "os": "android", "cpu": "arm64" }, "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ=="],
|
||||
|
||||
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.2.13", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA=="],
|
||||
|
||||
"@rolldown/binding-darwin-x64": ["@rolldown/binding-darwin-x64@1.2.13", "", { "os": "darwin", "cpu": "x64" }, "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw=="],
|
||||
|
||||
"@rolldown/binding-freebsd-x64": ["@rolldown/binding-freebsd-x64@1.2.13", "", { "os": "freebsd", "cpu": "x64" }, "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm-gnueabihf": ["@rolldown/binding-linux-arm-gnueabihf@1.2.13", "", { "os": "linux", "cpu": "arm" }, "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-gnu": ["@rolldown/binding-linux-arm64-gnu@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ=="],
|
||||
|
||||
"@rolldown/binding-linux-arm64-musl": ["@rolldown/binding-linux-arm64-musl@1.2.13", "", { "os": "linux", "cpu": "arm64" }, "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow=="],
|
||||
|
||||
"@rolldown/binding-linux-ppc64-gnu": ["@rolldown/binding-linux-ppc64-gnu@1.2.13", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig=="],
|
||||
|
||||
"@rolldown/binding-linux-s390x-gnu": ["@rolldown/binding-linux-s390x-gnu@1.2.13", "", { "os": "linux", "cpu": "s390x" }, "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-gnu": ["@rolldown/binding-linux-x64-gnu@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ=="],
|
||||
|
||||
"@rolldown/binding-linux-x64-musl": ["@rolldown/binding-linux-x64-musl@1.2.13", "", { "os": "linux", "cpu": "x64" }, "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw=="],
|
||||
|
||||
"@rolldown/binding-openharmony-arm64": ["@rolldown/binding-openharmony-arm64@1.2.13", "", { "os": "none", "cpu": "arm64" }, "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw=="],
|
||||
|
||||
"@rolldown/binding-win32-arm64-msvc": ["@rolldown/binding-win32-arm64-msvc@1.2.13", "", { "os": "win32", "cpu": "arm64" }, "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ=="],
|
||||
|
||||
"@rolldown/binding-win32-x64-msvc": ["@rolldown/binding-win32-x64-msvc@1.2.13", "", { "os": "win32", "cpu": "x64" }, "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg=="],
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.1", "", {}, "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.3.3", "", { "os": "android", "cpu": "arm64" }, "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.3.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.3.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.3.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.3.3", "", { "os": "linux", "cpu": "arm" }, "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.3.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.3.3", "", { "os": "linux", "cpu": "x64" }, "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.3.3", "", { "dependencies": { "@emnapi/core": "^1.11.1", "@emnapi/runtime": "^1.11.1", "@emnapi/wasi-threads": "^1.2.2", "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.2", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.3.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.3.3", "", { "os": "win32", "cpu": "x64" }, "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.3.3", "", { "dependencies": { "@tailwindcss/node": "4.3.3", "@tailwindcss/oxide": "4.3.3", "tailwindcss": "4.3.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw=="],
|
||||
|
||||
"@types/bcryptjs": ["@types/bcryptjs@3.0.0", "", { "dependencies": { "bcryptjs": "*" } }, "sha512-WRZOuCuaz8UcZZE4R5HXTco2goQSI2XxjGY3hbM/xDvwmqFWd4ivooImsMx65OKM6CtNKbnZ5YL+YwAwK7c1dg=="],
|
||||
|
||||
"@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="],
|
||||
|
||||
"@types/connect": ["@types/connect@3.4.38", "", { "dependencies": { "@types/node": "*" } }, "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug=="],
|
||||
|
||||
"@types/express": ["@types/express@4.17.25", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "^1" } }, "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw=="],
|
||||
|
||||
"@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.9", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg=="],
|
||||
|
||||
"@types/google.maps": ["@types/google.maps@3.66.6", "", {}, "sha512-F+m8NwLXXDf43/GsxmyceugqpBtKMaQGM/raqyINTG+r7fFgCXKxTZJ6Aw4QeMwUkIJwDtYVeUhndq3RFNkuDA=="],
|
||||
|
||||
"@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="],
|
||||
|
||||
"@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="],
|
||||
|
||||
"@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
|
||||
|
||||
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
|
||||
|
||||
"@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="],
|
||||
|
||||
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
||||
|
||||
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
||||
"@types/react-dom": ["@types/react-dom@19.3.0", "", { "peerDependencies": { "@types/react": "^19.3.0" } }, "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q=="],
|
||||
|
||||
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
||||
|
||||
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
||||
|
||||
"@types/serve-static": ["@types/serve-static@1.15.10", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "<1" } }, "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw=="],
|
||||
|
||||
"@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="],
|
||||
|
||||
"@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="],
|
||||
|
||||
"@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="],
|
||||
|
||||
"@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="],
|
||||
|
||||
"@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="],
|
||||
|
||||
"@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="],
|
||||
|
||||
"@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="],
|
||||
|
||||
"@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="],
|
||||
|
||||
"@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="],
|
||||
|
||||
"@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="],
|
||||
|
||||
"@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="],
|
||||
|
||||
"@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="],
|
||||
|
||||
"@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="],
|
||||
|
||||
"@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="],
|
||||
|
||||
"@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="],
|
||||
|
||||
"@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="],
|
||||
|
||||
"@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="],
|
||||
|
||||
"@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="],
|
||||
|
||||
"@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="],
|
||||
|
||||
"@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="],
|
||||
|
||||
"@vis.gl/react-google-maps": ["@vis.gl/react-google-maps@1.10.1", "", { "dependencies": { "@googlemaps/js-api-loader": "^2.1.1", "@types/google.maps": "^3.65.5", "fast-equals": "^6.0.2" }, "peerDependencies": { "react": ">=16.8.0 || ^19.0 || ^19.0.0-rc", "react-dom": ">=16.8.0 || ^19.0 || ^19.0.0-rc" } }, "sha512-2a6Bv04WcJIVN7IIjqygzVrmWKIgTXn/lmMdjeRWbKxX5FV6/Y3LhFScGJAKfAKqoAYIG6s31CWebhFUQdmBsw=="],
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.1.2", "", { "dependencies": { "@rolldown/pluginutils": "^1.0.1" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "oxc-transform-react": "^0.152.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler", "oxc-transform-react"] }, "sha512-fhJNGUdscQa4HkI0xFxwonWyhnVReCPDbXI7FH7jmzpnVCrFQI7PRrhWecO263610vOrs9tr2Y4xcf8uAdFxbw=="],
|
||||
|
||||
"accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
|
||||
"array-flatten": ["array-flatten@1.1.1", "", {}, "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg=="],
|
||||
|
||||
"autoprefixer": ["autoprefixer@10.6.1", "", { "dependencies": { "browserslist": "^4.28.9", "caniuse-lite": "^1.0.30001810", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-cL1Qz6ADZhcEbny/8HPfe99J6HhNoYtpX2LFLIbhgGE7Q1hlQVkYFdetDN7Id3KiQxhDrHwzlHr/YQCnZ8+xSA=="],
|
||||
|
||||
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.28", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA=="],
|
||||
|
||||
"bcryptjs": ["bcryptjs@3.0.3", "", { "bin": { "bcrypt": "bin/bcrypt" } }, "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g=="],
|
||||
|
||||
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.8", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.16.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA=="],
|
||||
|
||||
"browserslist": ["browserslist@4.29.3", "", { "dependencies": { "baseline-browser-mapping": "^2.11.26", "caniuse-lite": "^1.0.30001813", "electron-to-chromium": "^1.5.439", "node-releases": "^2.0.57", "update-browserslist-db": "^1.3.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
|
||||
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
|
||||
|
||||
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001815", "", {}, "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q=="],
|
||||
|
||||
"content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="],
|
||||
|
||||
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
|
||||
|
||||
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
||||
|
||||
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
||||
|
||||
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
|
||||
|
||||
"destroy": ["destroy@1.2.0", "", {}, "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
|
||||
|
||||
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.452", "", {}, "sha512-cVvk1YBSj6571VmV6hdJTqj94Y79xSu8cj6Y9t6ae4Y4431z8/7EJFvu3rVp90xhfuv9BpPKUmaLfTo4+FOGBg=="],
|
||||
|
||||
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
|
||||
|
||||
"enhanced-resolve": ["enhanced-resolve@5.26.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-9vhedylFonb2YGogzUKX6+Ja72gOJbN1QHAqdrvqLwhdl/QWbKopzoUC9EbQNVsAns/bx/4uyqalrQAoy1IByw=="],
|
||||
|
||||
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
||||
|
||||
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
|
||||
|
||||
"es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="],
|
||||
|
||||
"esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
|
||||
|
||||
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
|
||||
|
||||
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
|
||||
|
||||
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
|
||||
|
||||
"express": ["express@4.22.3", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.13", "proxy-addr": "~2.0.7", "qs": "~6.16.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw=="],
|
||||
|
||||
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
|
||||
|
||||
"fast-equals": ["fast-equals@6.1.1", "", {}, "sha512-VA9UvhVec1yj1UqkCLBCXgFBLbd6Pww2RGLa9biAHfl+/aOU9jTeP3idEtLLFouJIGDeugAxD/GhzHpjOSq4oA=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"finalhandler": ["finalhandler@1.3.2", "", { "dependencies": { "debug": "2.6.9", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "statuses": "~2.0.2", "unpipe": "~1.0.0" } }, "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg=="],
|
||||
|
||||
"formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
|
||||
|
||||
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
|
||||
|
||||
"fraction.js": ["fraction.js@5.3.4", "", {}, "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ=="],
|
||||
|
||||
"framer-motion": ["framer-motion@12.43.0", "", { "dependencies": { "motion-dom": "^12.43.0", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-1eaL3RvR/kAlbG7UYcpMptEyzPoENO0c6w7ZnB3/hh2vSAz/6uGAFn6fdoqTBguNstf3MsFhJHsD/0DHiclG+g=="],
|
||||
|
||||
"fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
|
||||
|
||||
"gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="],
|
||||
|
||||
"gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
|
||||
|
||||
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
||||
|
||||
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
|
||||
|
||||
"google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="],
|
||||
|
||||
"google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
|
||||
|
||||
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
|
||||
|
||||
"hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
|
||||
|
||||
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
|
||||
"iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
|
||||
|
||||
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||
|
||||
"json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
|
||||
|
||||
"jsonwebtoken": ["jsonwebtoken@9.0.3", "", { "dependencies": { "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", "lodash.isnumber": "^3.0.3", "lodash.isplainobject": "^4.0.6", "lodash.isstring": "^4.0.1", "lodash.once": "^4.0.0", "ms": "^2.1.1", "semver": "^7.5.4" } }, "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g=="],
|
||||
|
||||
"jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
|
||||
|
||||
"jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.33.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.33.0", "lightningcss-darwin-arm64": "1.33.0", "lightningcss-darwin-x64": "1.33.0", "lightningcss-freebsd-x64": "1.33.0", "lightningcss-linux-arm-gnueabihf": "1.33.0", "lightningcss-linux-arm64-gnu": "1.33.0", "lightningcss-linux-arm64-musl": "1.33.0", "lightningcss-linux-x64-gnu": "1.33.0", "lightningcss-linux-x64-musl": "1.33.0", "lightningcss-win32-arm64-msvc": "1.33.0", "lightningcss-win32-x64-msvc": "1.33.0" } }, "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.33.0", "", { "os": "android", "cpu": "arm64" }, "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.33.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.33.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.33.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.33.0", "", { "os": "linux", "cpu": "arm" }, "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.33.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.33.0", "", { "os": "linux", "cpu": "x64" }, "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.33.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.33.0", "", { "os": "win32", "cpu": "x64" }, "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA=="],
|
||||
|
||||
"lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="],
|
||||
|
||||
"lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="],
|
||||
|
||||
"lodash.isinteger": ["lodash.isinteger@4.0.4", "", {}, "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA=="],
|
||||
|
||||
"lodash.isnumber": ["lodash.isnumber@3.0.3", "", {}, "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw=="],
|
||||
|
||||
"lodash.isplainobject": ["lodash.isplainobject@4.0.6", "", {}, "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA=="],
|
||||
|
||||
"lodash.isstring": ["lodash.isstring@4.0.1", "", {}, "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw=="],
|
||||
|
||||
"lodash.once": ["lodash.once@4.1.1", "", {}, "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="],
|
||||
|
||||
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||
|
||||
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
|
||||
|
||||
"media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="],
|
||||
|
||||
"merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="],
|
||||
|
||||
"methods": ["methods@1.1.2", "", {}, "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w=="],
|
||||
|
||||
"mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"motion": ["motion@12.43.0", "", { "dependencies": { "framer-motion": "^12.43.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-BQgQbSa9Hn3/mtbib0MK53y6JSANa+YKUKlaYnWzAVDH424RYQ5LVpV3pNiWH00BA2z4ojsSdMzqT7g2FQwjuQ=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.43.0", "", { "dependencies": { "motion-utils": "^12.39.0" } }, "sha512-azKON4d9S65PEoFUiQTMTgPheEmzf2QngdRc50AKfJp9Q9mmcBVw22c8eMq9k8kxOFHdL7+WZY7N/5F/lwiDag=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.39.0", "", {}, "sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.20", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ=="],
|
||||
|
||||
"negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="],
|
||||
|
||||
"node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
|
||||
|
||||
"node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.58", "", {}, "sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg=="],
|
||||
|
||||
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
|
||||
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
|
||||
|
||||
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@0.1.13", "", {}, "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA=="],
|
||||
|
||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="],
|
||||
|
||||
"postcss": ["postcss@8.5.29", "", { "dependencies": { "nanoid": "^3.3.19", "picocolors": "^1.1.1", "source-map-js": "^1.2.2" } }, "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ=="],
|
||||
|
||||
"postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="],
|
||||
|
||||
"protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="],
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="],
|
||||
|
||||
"qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
|
||||
|
||||
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
|
||||
|
||||
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
||||
|
||||
"react": ["react@19.3.0", "", {}, "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog=="],
|
||||
|
||||
"react-dom": ["react-dom@19.3.0", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.0" } }, "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q=="],
|
||||
|
||||
"retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"rolldown": ["rolldown@1.2.13", "", { "dependencies": { "@oxc-project/types": "=0.153.0", "@rolldown/pluginutils": "^1.0.0" }, "optionalDependencies": { "@rolldown/binding-android-arm-eabi": "1.2.13", "@rolldown/binding-android-arm64": "1.2.13", "@rolldown/binding-darwin-arm64": "1.2.13", "@rolldown/binding-darwin-x64": "1.2.13", "@rolldown/binding-freebsd-x64": "1.2.13", "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", "@rolldown/binding-linux-arm64-gnu": "1.2.13", "@rolldown/binding-linux-arm64-musl": "1.2.13", "@rolldown/binding-linux-ppc64-gnu": "1.2.13", "@rolldown/binding-linux-s390x-gnu": "1.2.13", "@rolldown/binding-linux-x64-gnu": "1.2.13", "@rolldown/binding-linux-x64-musl": "1.2.13", "@rolldown/binding-openharmony-arm64": "1.2.13", "@rolldown/binding-win32-arm64-msvc": "1.2.13", "@rolldown/binding-win32-x64-msvc": "1.2.13" }, "bin": { "rolldown": "./bin/cli.mjs" } }, "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
||||
|
||||
"scheduler": ["scheduler@0.28.0", "", {}, "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw=="],
|
||||
|
||||
"semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="],
|
||||
|
||||
"send": ["send@0.19.2", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "~0.5.2", "http-errors": "~2.0.1", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "~2.4.1", "range-parser": "~1.2.1", "statuses": "~2.0.2" } }, "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg=="],
|
||||
|
||||
"serve-static": ["serve-static@1.16.3", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "~0.19.1" } }, "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA=="],
|
||||
|
||||
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
|
||||
|
||||
"side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="],
|
||||
|
||||
"side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="],
|
||||
|
||||
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
|
||||
|
||||
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.2", "", {}, "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw=="],
|
||||
|
||||
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||
|
||||
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="],
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"tsx": ["tsx@4.23.15", "", { "dependencies": { "esbuild": "~0.28.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw=="],
|
||||
|
||||
"type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="],
|
||||
|
||||
"typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
|
||||
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
|
||||
|
||||
"update-browserslist-db": ["update-browserslist-db@1.3.4", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A=="],
|
||||
|
||||
"utils-merge": ["utils-merge@1.0.1", "", {}, "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA=="],
|
||||
|
||||
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
|
||||
|
||||
"vite": ["vite@8.3.4", "", { "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", "postcss": "^8.5.29", "rolldown": "~1.2.12", "tinyglobby": "^0.2.17" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ=="],
|
||||
|
||||
"web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
|
||||
|
||||
"ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.3", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.3", "tslib": "^2.4.0" }, "bundled": true }, "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.3", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.5", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" }, "bundled": true }, "sha512-HshSvXzmBxNzqejd3k/KemgQ3hdREUYFRRrgxOZ+gPljDIsO2SDZrVZMd4Z/HsxudQbE62OVyVFE8z9BCurfjA=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.4", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-W3c4gRigFS0T/Ma4qIYF3GDAc5AQdHb1yL5znJT1Zv1YaD9Kitx656wBjvr19qbiosmZT8lWDM5BEMynUqX65A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"@types/body-parser/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/connect/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/express-serve-static-core/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/jsonwebtoken/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/send/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/serve-static/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"@types/serve-static/@types/send": ["@types/send@0.17.6", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og=="],
|
||||
|
||||
"debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"https-proxy-agent/debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@26.6.5", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg=="],
|
||||
|
||||
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="],
|
||||
|
||||
"@tailwindcss/node/lightningcss/lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"@types/body-parser/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"@types/connect/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"@types/express-serve-static-core/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"@types/jsonwebtoken/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"@types/send/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"@types/serve-static/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="],
|
||||
|
||||
"tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
{
|
||||
"users": [
|
||||
{
|
||||
"id": "usr_demo_golfer",
|
||||
"email": "demo@pinpointgolf.com",
|
||||
"username": "demogolfer",
|
||||
"name": "Demo Golfer",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 12.4,
|
||||
"homeClub": "Pebble Cove Links",
|
||||
"isPublic": true,
|
||||
"provider": "local",
|
||||
"createdAt": "2026-10-10T01:33:30.863Z",
|
||||
"updatedAt": "2026-10-10T01:33:30.863Z",
|
||||
"passwordHash": "$2b$10$dazakuysQGoPaG27E2OWweafH.l/GJrb3XGsCiBbRVgf2WdNfnwnO"
|
||||
},
|
||||
{
|
||||
"id": "usr_1791596044846_0e8u8",
|
||||
"email": "test@golf.com",
|
||||
"username": "testplayer",
|
||||
"name": "Test Player",
|
||||
"handicapIndex": 9.5,
|
||||
"homeClub": "Local Links Club",
|
||||
"isPublic": true,
|
||||
"provider": "local",
|
||||
"createdAt": "2026-10-10T01:34:04.846Z",
|
||||
"updatedAt": "2026-10-10T01:34:04.846Z",
|
||||
"passwordHash": "$2b$10$5UujhxPRuprn7xtAkOAxKeqfxLaIOIcTwEXtnFQnmVYy6wYVeIvCK"
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"usr_demo_golfer": {
|
||||
"trackFIR": true,
|
||||
"trackFIRDirection": true,
|
||||
"trackGIR": true,
|
||||
"trackGIRDirection": true,
|
||||
"trackPutts": true,
|
||||
"trackForegreen": true,
|
||||
"trackFirstPuttDistance": false,
|
||||
"trackPenalties": true,
|
||||
"trackPenaltyTypes": true,
|
||||
"trackBunkers": true,
|
||||
"trackBunkerTypes": true,
|
||||
"trackSandSaves": true,
|
||||
"trackUpAndDown": true,
|
||||
"trackDriveClub": true,
|
||||
"trackDriveDistance": false,
|
||||
"trackHoleNotes": true,
|
||||
"distanceUnit": "meters",
|
||||
"puttDistanceUnit": "meters"
|
||||
},
|
||||
"usr_1791596044846_0e8u8": {
|
||||
"trackFIR": true,
|
||||
"trackFIRDirection": true,
|
||||
"trackGIR": true,
|
||||
"trackGIRDirection": true,
|
||||
"trackPutts": true,
|
||||
"trackForegreen": true,
|
||||
"trackFirstPuttDistance": false,
|
||||
"trackPenalties": true,
|
||||
"trackPenaltyTypes": true,
|
||||
"trackBunkers": true,
|
||||
"trackBunkerTypes": true,
|
||||
"trackSandSaves": true,
|
||||
"trackUpAndDown": true,
|
||||
"trackDriveClub": true,
|
||||
"trackDriveDistance": false,
|
||||
"trackHoleNotes": true,
|
||||
"distanceUnit": "meters",
|
||||
"puttDistanceUnit": "meters"
|
||||
}
|
||||
},
|
||||
"rounds": {
|
||||
"usr_1791596044846_0e8u8": []
|
||||
},
|
||||
"customCourses": {},
|
||||
"publicSeedGolfers": [
|
||||
{
|
||||
"id": "user_seed_viktor",
|
||||
"username": "viktor_h",
|
||||
"name": "Viktor H.",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 0.4,
|
||||
"homeClub": "Oslo Golfklubb",
|
||||
"roundsCount": 24,
|
||||
"scoringAverage": 71.2,
|
||||
"bestGross": 67,
|
||||
"firPercentage": 74.5,
|
||||
"girPercentage": 76.2,
|
||||
"puttsPerRound": 28.4,
|
||||
"scramblePercentage": 68,
|
||||
"sandSavePercentage": 62.5,
|
||||
"penaltiesPerRound": 0.3,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_marcus",
|
||||
"username": "marcus_t",
|
||||
"name": "Marcus Thorne",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 5.8,
|
||||
"homeClub": "Pine Valley GC",
|
||||
"roundsCount": 18,
|
||||
"scoringAverage": 76.8,
|
||||
"bestGross": 73,
|
||||
"firPercentage": 66,
|
||||
"girPercentage": 62.5,
|
||||
"puttsPerRound": 30.1,
|
||||
"scramblePercentage": 54,
|
||||
"sandSavePercentage": 47,
|
||||
"penaltiesPerRound": 0.8,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_sarah",
|
||||
"username": "sarah_m",
|
||||
"name": "Sarah Miller",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 11.2,
|
||||
"homeClub": "St Andrews Links Club",
|
||||
"roundsCount": 15,
|
||||
"scoringAverage": 82.4,
|
||||
"bestGross": 78,
|
||||
"firPercentage": 58.2,
|
||||
"girPercentage": 51,
|
||||
"puttsPerRound": 31.6,
|
||||
"scramblePercentage": 42,
|
||||
"sandSavePercentage": 36,
|
||||
"penaltiesPerRound": 1.4,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_dave",
|
||||
"username": "dave_k",
|
||||
"name": "Dave Kowalski",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 18.6,
|
||||
"homeClub": "Bandon Dunes Golfers",
|
||||
"roundsCount": 12,
|
||||
"scoringAverage": 91.5,
|
||||
"bestGross": 86,
|
||||
"firPercentage": 44,
|
||||
"girPercentage": 32.5,
|
||||
"puttsPerRound": 34.2,
|
||||
"scramblePercentage": 28,
|
||||
"sandSavePercentage": 21,
|
||||
"penaltiesPerRound": 2.6,
|
||||
"isPublic": true
|
||||
},
|
||||
{
|
||||
"id": "user_seed_elena",
|
||||
"username": "elena_rostova",
|
||||
"name": "Elena Rostova",
|
||||
"avatarUrl": "https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80",
|
||||
"handicapIndex": 8.9,
|
||||
"homeClub": "Torrey Pines South",
|
||||
"roundsCount": 20,
|
||||
"scoringAverage": 79.8,
|
||||
"bestGross": 75,
|
||||
"firPercentage": 62.4,
|
||||
"girPercentage": 57.8,
|
||||
"puttsPerRound": 31,
|
||||
"scramblePercentage": 48,
|
||||
"sandSavePercentage": 40,
|
||||
"penaltiesPerRound": 1.1,
|
||||
"isPublic": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
pinpoint-golf:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Pass your Google Maps API key at build time (or load from .env)
|
||||
VITE_GOOGLE_MAPS_API_KEY: ${VITE_GOOGLE_MAPS_API_KEY:-}
|
||||
container_name: pinpoint-golf-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
# Map host port 3000 to app port 3000
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- PORT=3000
|
||||
- JWT_SECRET=${JWT_SECRET:-pinpoint_golf_prod_secret_2026}
|
||||
# Google OAuth Credentials (optional)
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID:-}
|
||||
# Authentik / OIDC Configuration (optional)
|
||||
- AUTHENTIK_ISSUER_URL=${AUTHENTIK_ISSUER_URL:-}
|
||||
- AUTHENTIK_CLIENT_ID=${AUTHENTIK_CLIENT_ID:-}
|
||||
- AUTHENTIK_CLIENT_SECRET=${AUTHENTIK_CLIENT_SECRET:-}
|
||||
# GitHub OAuth Credentials (optional)
|
||||
- GITHUB_CLIENT_ID=${GITHUB_CLIENT_ID:-}
|
||||
- GITHUB_CLIENT_SECRET=${GITHUB_CLIENT_SECRET:-}
|
||||
volumes:
|
||||
# Persistent volume for users, scorecards, custom courses, and community stats
|
||||
- pinpoint_data:/app/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/healthz"]
|
||||
interval: 30s
|
||||
timeout: 3s
|
||||
retries: 3
|
||||
start_period: 5s
|
||||
|
||||
volumes:
|
||||
pinpoint_data:
|
||||
driver: local
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<title>PinPoint Golf - Smart Scorecard & Shot Tracker</title>
|
||||
<meta name="description" content="Comprehensive golf scorecard with advanced stats tracking: FIR, GIR, putts, foregreen fringe putts, fairway vs. greenside bunkers, penalty types (water, OB, lost ball), and customizable tracking settings." />
|
||||
<meta property="og:title" content="PinPoint Golf - Smart Scorecard & Shot Tracker" />
|
||||
<meta property="og:description" content="Comprehensive golf scorecard with advanced stats tracking: FIR, GIR, putts, foregreen fringe putts, fairway vs. greenside bunkers, penalty types (water, OB, lost ball), and customizable tracking settings." />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta name="twitter:card" content="summary_large_image" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body class="bg-stone-900 text-stone-100 antialiased selection:bg-emerald-500 selection:text-white">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "PinPoint Golf - Smart Scorecard & Shot Tracker",
|
||||
"description": "Comprehensive golf scorecard with advanced stats tracking: FIR, GIR, putts, foregreen fringe putts, fairway vs. greenside bunkers, penalty types (water, OB, lost ball), and customizable tracking settings.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html index.htm;
|
||||
|
||||
# Gzip compression for high performance
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
||||
|
||||
# SPA routing: fallback to index.html for client-side routing
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Cache static assets with long TTL
|
||||
location /assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, no-transform, immutable";
|
||||
}
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
|
||||
# Health check endpoint
|
||||
location /healthz {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx server.ts",
|
||||
"start": "tsx server.ts",
|
||||
"build": "vite build && esbuild server.ts --bundle --platform=node --target=node22 --outfile=dist/server.cjs --external:express --external:bcryptjs --external:jsonwebtoken --external:vite",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js data/pinpoint_db.json.tmp",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@vis.gl/react-google-maps": "^1.10.1",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^3.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import express, { Request, Response } from 'express';
|
||||
import path from 'path';
|
||||
import dotenv from 'dotenv';
|
||||
import authRoutes from './src/server/routes/authRoutes';
|
||||
import roundsRoutes from './src/server/routes/roundsRoutes';
|
||||
import communityRoutes from './src/server/routes/communityRoutes';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const app = express();
|
||||
const PORT = 3000;
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Body parsers
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/healthz', (_req: Request, res: Response) => {
|
||||
res.status(200).send('healthy\n');
|
||||
});
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/rounds', roundsRoutes);
|
||||
app.use('/api/community', communityRoutes);
|
||||
|
||||
async function startServer() {
|
||||
if (!isProduction) {
|
||||
// Development mode: Vite middleware
|
||||
const { createServer: createViteServer } = await import('vite');
|
||||
const vite = await createViteServer({
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
appType: 'spa',
|
||||
});
|
||||
app.use(vite.middlewares);
|
||||
} else {
|
||||
// Production mode: Serve static build assets
|
||||
const distPath = path.resolve(process.cwd(), 'dist');
|
||||
app.use(express.static(distPath));
|
||||
app.get('*', (_req: Request, res: Response) => {
|
||||
res.sendFile(path.join(distPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`🏌️♂️ PinPoint Golf full-stack server running on http://0.0.0.0:${PORT}`);
|
||||
});
|
||||
}
|
||||
|
||||
startServer().catch((err) => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
+491
@@ -0,0 +1,491 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Course, HoleScore, Round, TrackingSettings, Achievement } from './types/golf';
|
||||
import { DEFAULT_COURSES, DEFAULT_TRACKING_SETTINGS } from './data/courses';
|
||||
import { SAMPLE_ROUND } from './data/sampleRounds';
|
||||
import { Navbar, ActiveTab } from './components/Navbar';
|
||||
import { HoleEntry } from './components/HoleEntry';
|
||||
import { Scorecard } from './components/Scorecard';
|
||||
import { Analytics } from './components/Analytics';
|
||||
import { Settings } from './components/Settings';
|
||||
import { NewRoundModal } from './components/NewRoundModal';
|
||||
import { RoundsHistoryModal } from './components/RoundsHistoryModal';
|
||||
import { AchievementsModal } from './components/AchievementsModal';
|
||||
import { CourseEditorModal } from './components/CourseEditorModal';
|
||||
import { AuthModal } from './components/AuthModal';
|
||||
import { CommunityComparisonModal } from './components/CommunityComparisonModal';
|
||||
import { calculateRoundStats } from './utils/golfStats';
|
||||
import { evaluateAchievements } from './utils/achievements';
|
||||
import { AuthProviderComponent, useAuth } from './context/AuthContext';
|
||||
import { roundsApi, authApi } from './services/api';
|
||||
import { Trophy, X, Sparkles, CloudCheck, Cloud } from 'lucide-react';
|
||||
|
||||
const STORAGE_KEY_ROUNDS = 'pinpoint_golf_rounds_v1';
|
||||
const STORAGE_KEY_SETTINGS = 'pinpoint_golf_settings_v1';
|
||||
const STORAGE_KEY_COURSES = 'pinpoint_golf_custom_courses_v1';
|
||||
const STORAGE_KEY_UNLOCKED_ACHIEVEMENTS = 'pinpoint_golf_unlocked_achievements_v1';
|
||||
|
||||
function AppContent() {
|
||||
const { user } = useAuth();
|
||||
|
||||
// 1. Settings state
|
||||
const [settings, setSettings] = useState<TrackingSettings>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_SETTINGS);
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return DEFAULT_TRACKING_SETTINGS;
|
||||
});
|
||||
|
||||
// 2. Courses state (presets + user custom / imported)
|
||||
const [courses, setCourses] = useState<Course[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_COURSES);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
const combined = [...DEFAULT_COURSES];
|
||||
parsed.forEach((c: Course) => {
|
||||
if (!combined.some((ex) => ex.id === c.id)) {
|
||||
combined.push(c);
|
||||
}
|
||||
});
|
||||
return combined;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return DEFAULT_COURSES;
|
||||
});
|
||||
|
||||
// 3. Rounds state (or seed with SAMPLE_ROUND)
|
||||
const [rounds, setRounds] = useState<Round[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_ROUNDS);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed) && parsed.length > 0) return parsed;
|
||||
}
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return [SAMPLE_ROUND];
|
||||
});
|
||||
|
||||
const [currentRoundId, setCurrentRoundId] = useState<string>(() => {
|
||||
return rounds[0]?.id || SAMPLE_ROUND.id;
|
||||
});
|
||||
|
||||
const [currentHoleNumber, setCurrentHoleNumber] = useState<number>(1);
|
||||
const [activeTab, setActiveTab] = useState<ActiveTab>('hole');
|
||||
|
||||
// 4. Achievements state
|
||||
const [unlockedIds, setUnlockedIds] = useState<string[]>(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS);
|
||||
if (saved) return JSON.parse(saved);
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
const [achievements, setAchievements] = useState<Achievement[]>([]);
|
||||
const [newlyUnlockedToast, setNewlyUnlockedToast] = useState<Achievement | null>(null);
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Modals state
|
||||
const [isNewRoundModalOpen, setIsNewRoundModalOpen] = useState(false);
|
||||
const [isRoundsHistoryModalOpen, setIsRoundsHistoryModalOpen] = useState(false);
|
||||
const [isAchievementsModalOpen, setIsAchievementsModalOpen] = useState(false);
|
||||
const [isCourseEditorModalOpen, setIsCourseEditorModalOpen] = useState(false);
|
||||
const [courseEditorBaseCourse, setCourseEditorBaseCourse] = useState<Course | null>(null);
|
||||
const [isAuthModalOpen, setIsAuthModalOpen] = useState(false);
|
||||
const [isCommunityModalOpen, setIsCommunityModalOpen] = useState(false);
|
||||
|
||||
// Sync state with cloud when user logs in
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const loadCloudData = async () => {
|
||||
try {
|
||||
const [cloudSettings, cloudRounds, cloudCourses] = await Promise.all([
|
||||
authApi.getSettings().catch(() => null),
|
||||
roundsApi.getRounds().catch(() => []),
|
||||
roundsApi.getCustomCourses().catch(() => []),
|
||||
]);
|
||||
|
||||
if (cloudSettings) {
|
||||
setSettings(cloudSettings);
|
||||
}
|
||||
|
||||
if (cloudRounds && cloudRounds.length > 0) {
|
||||
// Merge cloud rounds with local rounds without duplicates
|
||||
setRounds((prev) => {
|
||||
const merged = [...cloudRounds];
|
||||
for (const r of prev) {
|
||||
if (!merged.some((m) => m.id === r.id)) {
|
||||
merged.push(r);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
if (cloudCourses && cloudCourses.length > 0) {
|
||||
setCourses((prev) => {
|
||||
const combined = [...prev];
|
||||
for (const cc of cloudCourses) {
|
||||
if (!combined.some((c) => c.id === cc.id)) {
|
||||
combined.push(cc);
|
||||
}
|
||||
}
|
||||
return combined;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error syncing cloud data:', err);
|
||||
}
|
||||
};
|
||||
|
||||
loadCloudData();
|
||||
}, [user?.id]);
|
||||
|
||||
// Sync state to LocalStorage
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_ROUNDS, JSON.stringify(rounds));
|
||||
} catch (e) {
|
||||
console.error('Failed to save rounds to localStorage', e);
|
||||
}
|
||||
}, [rounds]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_SETTINGS, JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings to localStorage', e);
|
||||
}
|
||||
}, [settings]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
const customOnly = courses.filter((c) => c.isCustom);
|
||||
localStorage.setItem(STORAGE_KEY_COURSES, JSON.stringify(customOnly));
|
||||
} catch (e) {
|
||||
console.error('Failed to save custom courses to localStorage', e);
|
||||
}
|
||||
}, [courses]);
|
||||
|
||||
// Evaluate achievements whenever rounds or courses change
|
||||
useEffect(() => {
|
||||
const evaluation = evaluateAchievements(rounds, courses, unlockedIds);
|
||||
setAchievements(evaluation.unlockedList);
|
||||
|
||||
const currentUnlockedIds = evaluation.unlockedList.filter((a) => a.isUnlocked).map((a) => a.id);
|
||||
if (JSON.stringify(currentUnlockedIds) !== JSON.stringify(unlockedIds)) {
|
||||
setUnlockedIds(currentUnlockedIds);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY_UNLOCKED_ACHIEVEMENTS, JSON.stringify(currentUnlockedIds));
|
||||
} catch {
|
||||
// fallback
|
||||
}
|
||||
}
|
||||
|
||||
if (!isInitialMount.current && evaluation.newlyUnlocked.length > 0) {
|
||||
setNewlyUnlockedToast(evaluation.newlyUnlocked[0]);
|
||||
}
|
||||
|
||||
isInitialMount.current = false;
|
||||
}, [rounds, courses]);
|
||||
|
||||
// Current active round object
|
||||
const currentRound = rounds.find((r) => r.id === currentRoundId) || rounds[0] || SAMPLE_ROUND;
|
||||
const currentCourse: Course =
|
||||
courses.find((c) => c.id === currentRound.courseId) ||
|
||||
DEFAULT_COURSES.find((c) => c.id === currentRound.courseId) ||
|
||||
courses[0] ||
|
||||
DEFAULT_COURSES[0];
|
||||
|
||||
// Update a specific hole's score
|
||||
const handleUpdateHoleScore = (holeNumber: number, updated: HoleScore) => {
|
||||
setRounds((prevRounds) => {
|
||||
const updatedRounds = prevRounds.map((r) => {
|
||||
if (r.id !== currentRound.id) return r;
|
||||
const modified = {
|
||||
...r,
|
||||
scores: {
|
||||
...r.scores,
|
||||
[holeNumber]: updated,
|
||||
},
|
||||
};
|
||||
|
||||
// If user is authenticated, save directly to cloud database
|
||||
if (user) {
|
||||
roundsApi.saveRound(modified).catch((e) => console.warn('Cloud save error:', e));
|
||||
}
|
||||
|
||||
return modified;
|
||||
});
|
||||
return updatedRounds;
|
||||
});
|
||||
};
|
||||
|
||||
const handleStartNewRound = (newRound: Round) => {
|
||||
setRounds((prev) => [newRound, ...prev]);
|
||||
setCurrentRoundId(newRound.id);
|
||||
setCurrentHoleNumber(1);
|
||||
setActiveTab('hole');
|
||||
|
||||
if (user) {
|
||||
roundsApi.saveRound(newRound).catch((e) => console.warn('Cloud save error:', e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRound = (roundId: string) => {
|
||||
if (rounds.length <= 1) return;
|
||||
const remaining = rounds.filter((r) => r.id !== roundId);
|
||||
setRounds(remaining);
|
||||
if (currentRoundId === roundId) {
|
||||
setCurrentRoundId(remaining[0].id);
|
||||
}
|
||||
|
||||
if (user) {
|
||||
roundsApi.deleteRound(roundId).catch((e) => console.warn('Cloud delete error:', e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleLoadSampleRound = () => {
|
||||
const exists = rounds.some((r) => r.id === SAMPLE_ROUND.id);
|
||||
if (!exists) {
|
||||
setRounds((prev) => [SAMPLE_ROUND, ...prev]);
|
||||
}
|
||||
setCurrentRoundId(SAMPLE_ROUND.id);
|
||||
setCurrentHoleNumber(1);
|
||||
setActiveTab('analytics');
|
||||
};
|
||||
|
||||
const handleEditHoleFromScorecard = (holeNumber: number) => {
|
||||
setCurrentHoleNumber(holeNumber);
|
||||
setActiveTab('hole');
|
||||
};
|
||||
|
||||
const handleUpdateSettings = (newSettings: TrackingSettings) => {
|
||||
setSettings(newSettings);
|
||||
if (user) {
|
||||
authApi.updateSettings(newSettings).catch((e) => console.warn('Cloud settings update error:', e));
|
||||
}
|
||||
};
|
||||
|
||||
// Course Management Handlers
|
||||
const handleOpenCourseEditor = (baseCourse?: Course | null) => {
|
||||
setCourseEditorBaseCourse(baseCourse || null);
|
||||
setIsCourseEditorModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSaveCustomCourse = (savedCourse: Course) => {
|
||||
setCourses((prev) => {
|
||||
const existingIndex = prev.findIndex((c) => c.id === savedCourse.id);
|
||||
if (existingIndex >= 0) {
|
||||
const copy = [...prev];
|
||||
copy[existingIndex] = savedCourse;
|
||||
return copy;
|
||||
}
|
||||
return [savedCourse, ...prev];
|
||||
});
|
||||
|
||||
if (user) {
|
||||
roundsApi.saveCustomCourse(savedCourse).catch((e) => console.warn('Cloud course save error:', e));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCustomCourse = (courseId: string) => {
|
||||
setCourses((prev) => prev.filter((c) => c.id !== courseId));
|
||||
};
|
||||
|
||||
const handleCourseImported = (importedCourse: Course) => {
|
||||
setCourses((prev) => {
|
||||
if (prev.some((c) => c.id === importedCourse.id)) return prev;
|
||||
return [importedCourse, ...prev];
|
||||
});
|
||||
};
|
||||
|
||||
const handleSyncLocalRoundsPrompt = async () => {
|
||||
if (user && rounds.length > 0) {
|
||||
try {
|
||||
await roundsApi.syncBatch(rounds);
|
||||
} catch (err) {
|
||||
console.warn('Sync batch error:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Quick summary stats for the top bar
|
||||
const summary = calculateRoundStats(currentRound, currentCourse);
|
||||
const unlockedAchievementsCount = achievements.filter((a) => a.isUnlocked).length;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-stone-950 text-stone-100 flex flex-col font-sans relative">
|
||||
{/* Newly Unlocked Achievement Toast Notification */}
|
||||
{newlyUnlockedToast && (
|
||||
<div className="fixed top-20 right-4 z-50 max-w-sm w-full bg-gradient-to-r from-stone-900 to-amber-950/80 border-2 border-amber-500/80 rounded-2xl p-4 shadow-2xl animate-bounce-subtle flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-xl bg-amber-500 text-stone-900 flex items-center justify-center shrink-0 shadow-md">
|
||||
<Trophy className="w-5 h-5 font-bold" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 text-[10px] font-extrabold uppercase text-amber-400 tracking-wider">
|
||||
<Sparkles className="w-3 h-3 text-amber-400" />
|
||||
<span>Trophy Unlocked!</span>
|
||||
</div>
|
||||
<h4 className="font-extrabold text-sm text-white mt-0.5 truncate">
|
||||
{newlyUnlockedToast.title}
|
||||
</h4>
|
||||
<p className="text-xs text-stone-300 mt-0.5 line-clamp-2">
|
||||
{newlyUnlockedToast.description}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => {
|
||||
setNewlyUnlockedToast(null);
|
||||
setIsAchievementsModalOpen(true);
|
||||
}}
|
||||
className="text-[11px] font-bold text-amber-400 hover:text-amber-300 underline mt-1.5 inline-block"
|
||||
>
|
||||
View Trophies →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setNewlyUnlockedToast(null)}
|
||||
className="p-1 rounded-lg text-stone-400 hover:text-white"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3-Zone Navigation Header */}
|
||||
<Navbar
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
currentRound={currentRound}
|
||||
onNewRound={() => setIsNewRoundModalOpen(true)}
|
||||
onOpenRounds={() => setIsRoundsHistoryModalOpen(true)}
|
||||
onOpenAchievements={() => setIsAchievementsModalOpen(true)}
|
||||
onOpenCommunity={() => setIsCommunityModalOpen(true)}
|
||||
onOpenAuth={() => setIsAuthModalOpen(true)}
|
||||
achievementsUnlockedCount={unlockedAchievementsCount}
|
||||
scoreSummary={{
|
||||
totalGross: summary.totalGross,
|
||||
toPar: summary.toPar,
|
||||
holesPlayedCount: summary.holesPlayedCount,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Main App Content Viewport */}
|
||||
<main className="flex-1 w-full max-w-7xl mx-auto px-2 sm:px-4 py-4">
|
||||
{activeTab === 'hole' && (
|
||||
<HoleEntry
|
||||
currentHoleNumber={currentHoleNumber}
|
||||
setCurrentHoleNumber={setCurrentHoleNumber}
|
||||
course={currentCourse}
|
||||
scores={currentRound.scores}
|
||||
onUpdateHoleScore={handleUpdateHoleScore}
|
||||
settings={settings}
|
||||
onViewScorecard={() => setActiveTab('scorecard')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'scorecard' && (
|
||||
<Scorecard
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
onEditHole={handleEditHoleFromScorecard}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'analytics' && (
|
||||
<Analytics
|
||||
round={currentRound}
|
||||
course={currentCourse}
|
||||
settings={settings}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'settings' && (
|
||||
<Settings
|
||||
settings={settings}
|
||||
onUpdateSettings={handleUpdateSettings}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* New Round Setup Modal (with Public API Search & Course Library) */}
|
||||
<NewRoundModal
|
||||
isOpen={isNewRoundModalOpen}
|
||||
onClose={() => setIsNewRoundModalOpen(false)}
|
||||
onStartRound={handleStartNewRound}
|
||||
currentSettings={settings}
|
||||
courses={courses}
|
||||
onOpenCourseEditor={handleOpenCourseEditor}
|
||||
onDeleteCustomCourse={handleDeleteCustomCourse}
|
||||
onCourseImported={handleCourseImported}
|
||||
/>
|
||||
|
||||
{/* Saved Rounds & History Modal */}
|
||||
<RoundsHistoryModal
|
||||
isOpen={isRoundsHistoryModalOpen}
|
||||
onClose={() => setIsRoundsHistoryModalOpen(false)}
|
||||
rounds={rounds}
|
||||
currentRoundId={currentRoundId}
|
||||
onSelectRound={(id) => setCurrentRoundId(id)}
|
||||
onDeleteRound={handleDeleteRound}
|
||||
onLoadSampleRound={handleLoadSampleRound}
|
||||
/>
|
||||
|
||||
{/* Achievements / Trophies Showcase Modal */}
|
||||
<AchievementsModal
|
||||
isOpen={isAchievementsModalOpen}
|
||||
onClose={() => setIsAchievementsModalOpen(false)}
|
||||
achievements={achievements}
|
||||
/>
|
||||
|
||||
{/* Custom Course Builder & Modifier Modal */}
|
||||
<CourseEditorModal
|
||||
isOpen={isCourseEditorModalOpen}
|
||||
onClose={() => setIsCourseEditorModalOpen(false)}
|
||||
baseCourse={courseEditorBaseCourse}
|
||||
onSaveCourse={handleSaveCustomCourse}
|
||||
/>
|
||||
|
||||
{/* Auth & Identity Modal */}
|
||||
<AuthModal
|
||||
isOpen={isAuthModalOpen}
|
||||
onClose={() => setIsAuthModalOpen(false)}
|
||||
onSettingsSynced={(synced) => setSettings(synced)}
|
||||
onSyncLocalRoundsPrompt={handleSyncLocalRoundsPrompt}
|
||||
/>
|
||||
|
||||
{/* Community Benchmark & Public Leaderboard Modal */}
|
||||
<CommunityComparisonModal
|
||||
isOpen={isCommunityModalOpen}
|
||||
onClose={() => setIsCommunityModalOpen(false)}
|
||||
onOpenAuthModal={() => {
|
||||
setIsCommunityModalOpen(false);
|
||||
setIsAuthModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<AuthProviderComponent>
|
||||
<AppContent />
|
||||
</AuthProviderComponent>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 876 KiB |
@@ -0,0 +1,328 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Trophy,
|
||||
Award,
|
||||
Lock,
|
||||
CheckCircle2,
|
||||
Sparkles,
|
||||
Flame,
|
||||
Star,
|
||||
Target,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Crown,
|
||||
Medal,
|
||||
Rocket,
|
||||
Sun,
|
||||
ShieldCheck,
|
||||
CheckCheck,
|
||||
Zap,
|
||||
Gem,
|
||||
Flag,
|
||||
} from 'lucide-react';
|
||||
import { Achievement, AchievementTier } from '../types/golf';
|
||||
|
||||
interface AchievementsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
achievements: Achievement[];
|
||||
}
|
||||
|
||||
export const AchievementsModal: React.FC<AchievementsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
achievements,
|
||||
}) => {
|
||||
const [tierFilter, setTierFilter] = useState<'all' | AchievementTier>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'unlocked' | 'locked'>('all');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const totalCount = achievements.length;
|
||||
const unlockedCount = achievements.filter((a) => a.isUnlocked).length;
|
||||
const percentage = Math.round((unlockedCount / totalCount) * 100);
|
||||
|
||||
// Counts by tier
|
||||
const tierCounts: Record<AchievementTier, { unlocked: number; total: number }> = {
|
||||
bronze: {
|
||||
unlocked: achievements.filter((a) => a.tier === 'bronze' && a.isUnlocked).length,
|
||||
total: achievements.filter((a) => a.tier === 'bronze').length,
|
||||
},
|
||||
silver: {
|
||||
unlocked: achievements.filter((a) => a.tier === 'silver' && a.isUnlocked).length,
|
||||
total: achievements.filter((a) => a.tier === 'silver').length,
|
||||
},
|
||||
gold: {
|
||||
unlocked: achievements.filter((a) => a.tier === 'gold' && a.isUnlocked).length,
|
||||
total: achievements.filter((a) => a.tier === 'gold').length,
|
||||
},
|
||||
platinum: {
|
||||
unlocked: achievements.filter((a) => a.tier === 'platinum' && a.isUnlocked).length,
|
||||
total: achievements.filter((a) => a.tier === 'platinum').length,
|
||||
},
|
||||
};
|
||||
|
||||
const filtered = achievements.filter((a) => {
|
||||
if (tierFilter !== 'all' && a.tier !== tierFilter) return false;
|
||||
if (statusFilter === 'unlocked' && !a.isUnlocked) return false;
|
||||
if (statusFilter === 'locked' && a.isUnlocked) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const getTierBadgeStyle = (tier: AchievementTier) => {
|
||||
switch (tier) {
|
||||
case 'bronze':
|
||||
return 'bg-amber-900/30 text-amber-300 border-amber-700/50';
|
||||
case 'silver':
|
||||
return 'bg-slate-800 text-slate-200 border-slate-600/60';
|
||||
case 'gold':
|
||||
return 'bg-amber-500/20 text-amber-300 border-amber-500/50';
|
||||
case 'platinum':
|
||||
return 'bg-cyan-950/40 text-cyan-300 border-cyan-500/60 ring-1 ring-cyan-500/30';
|
||||
}
|
||||
};
|
||||
|
||||
const renderIcon = (iconName: string, isUnlocked: boolean) => {
|
||||
const props = { className: `w-5 h-5 ${isUnlocked ? 'text-white' : 'text-stone-500'}` };
|
||||
switch (iconName) {
|
||||
case 'Flag':
|
||||
return <Flag {...props} />;
|
||||
case 'Award':
|
||||
return <Award {...props} />;
|
||||
case 'Trophy':
|
||||
return <Trophy {...props} />;
|
||||
case 'Compass':
|
||||
return <Compass {...props} />;
|
||||
case 'Flame':
|
||||
return <Flame {...props} />;
|
||||
case 'Crosshair':
|
||||
return <Crosshair {...props} />;
|
||||
case 'Target':
|
||||
return <Target {...props} />;
|
||||
case 'ShieldCheck':
|
||||
return <ShieldCheck {...props} />;
|
||||
case 'CheckCircle2':
|
||||
return <CheckCircle2 {...props} />;
|
||||
case 'Crown':
|
||||
return <Crown {...props} />;
|
||||
case 'Medal':
|
||||
return <Medal {...props} />;
|
||||
case 'Sun':
|
||||
return <Sun {...props} />;
|
||||
case 'Rocket':
|
||||
return <Rocket {...props} />;
|
||||
case 'Star':
|
||||
return <Star {...props} />;
|
||||
case 'CheckCheck':
|
||||
return <CheckCheck {...props} />;
|
||||
case 'Zap':
|
||||
return <Zap {...props} />;
|
||||
case 'Gem':
|
||||
return <Gem {...props} />;
|
||||
case 'Sparkles':
|
||||
default:
|
||||
return <Sparkles {...props} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-3xl w-full p-5 sm:p-6 shadow-2xl relative max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-stone-800 shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-gradient-to-br from-amber-500 to-amber-700 flex items-center justify-center text-stone-900 shadow-md">
|
||||
<Trophy className="w-5 h-5 text-stone-900 font-bold" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-white">Golf Trophies & Feats</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
From milestone scores to rare feats like driving a Par 4 green or hole-in-one
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Global Progress Strip */}
|
||||
<div className="my-4 p-4 rounded-2xl bg-stone-850 border border-stone-800 shrink-0 space-y-3">
|
||||
<div className="flex items-center justify-between text-xs font-mono">
|
||||
<span className="text-stone-300 font-sans font-bold">Career Trophies Unlocked:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-emerald-400 font-bold text-sm">
|
||||
{unlockedCount} / {totalCount}
|
||||
</span>
|
||||
<span className="text-stone-500">({percentage}%)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-stone-800 h-2.5 rounded-full overflow-hidden">
|
||||
<div
|
||||
style={{ width: `${percentage}%` }}
|
||||
className="bg-gradient-to-r from-emerald-500 to-teal-400 h-full rounded-full transition-all duration-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tier Counts Pill Bar */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 pt-1">
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-400 font-bold uppercase block">Bronze</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.bronze.unlocked} / {tierCounts.bronze.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-slate-300 font-bold uppercase block">Silver</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.silver.unlocked} / {tierCounts.silver.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-amber-300 font-bold uppercase block">Gold</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.gold.unlocked} / {tierCounts.gold.total}
|
||||
</span>
|
||||
</div>
|
||||
<div className="bg-stone-900/60 p-2 rounded-xl border border-stone-800 text-center">
|
||||
<span className="text-[10px] text-cyan-300 font-bold uppercase block">Platinum</span>
|
||||
<span className="text-xs font-mono font-bold text-white">
|
||||
{tierCounts.platinum.unlocked} / {tierCounts.platinum.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 pb-3 shrink-0">
|
||||
{/* Tier Filter Tabs */}
|
||||
<div className="flex items-center gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{(['all', 'bronze', 'silver', 'gold', 'platinum'] as const).map((tier) => (
|
||||
<button
|
||||
key={tier}
|
||||
onClick={() => setTierFilter(tier)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-bold capitalize transition-all ${
|
||||
tierFilter === tier
|
||||
? 'bg-stone-700 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-stone-200 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
{tier}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
onClick={() => setStatusFilter('all')}
|
||||
className={`px-2.5 py-1 rounded-lg ${
|
||||
statusFilter === 'all' ? 'bg-emerald-600/30 text-emerald-300 font-bold' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('unlocked')}
|
||||
className={`px-2.5 py-1 rounded-lg ${
|
||||
statusFilter === 'unlocked' ? 'bg-emerald-600/30 text-emerald-300 font-bold' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
Unlocked ({unlockedCount})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setStatusFilter('locked')}
|
||||
className={`px-2.5 py-1 rounded-lg ${
|
||||
statusFilter === 'locked' ? 'bg-emerald-600/30 text-emerald-300 font-bold' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
Locked ({totalCount - unlockedCount})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Achievements List */}
|
||||
<div className="space-y-2.5 overflow-y-auto flex-1 pr-1.5">
|
||||
{filtered.map((ach) => {
|
||||
const isUnlocked = ach.isUnlocked;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ach.id}
|
||||
className={`p-3.5 sm:p-4 rounded-2xl border transition-all flex items-start justify-between gap-4 ${
|
||||
isUnlocked
|
||||
? 'bg-stone-850 border-stone-750 shadow-sm'
|
||||
: 'bg-stone-900/60 border-stone-800/80 opacity-70'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-3.5">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 border ${
|
||||
isUnlocked
|
||||
? getTierBadgeStyle(ach.tier)
|
||||
: 'bg-stone-800/50 border-stone-700/50 text-stone-600'
|
||||
}`}
|
||||
>
|
||||
{isUnlocked ? renderIcon(ach.iconName, true) : <Lock className="w-4 h-4 text-stone-600" />}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h4 className="font-bold text-sm text-white">{ach.title}</h4>
|
||||
<span
|
||||
className={`text-[9px] font-extrabold uppercase px-1.5 py-0.5 rounded border tracking-wider ${getTierBadgeStyle(
|
||||
ach.tier
|
||||
)}`}
|
||||
>
|
||||
{ach.tier}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-stone-400 mt-1 leading-snug">
|
||||
{ach.description}
|
||||
</p>
|
||||
|
||||
{isUnlocked && ach.unlockedRoundName && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-emerald-400 font-mono mt-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Unlocked at: {ach.unlockedRoundName} ({ach.unlockedAt})</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 self-center">
|
||||
{isUnlocked ? (
|
||||
<span className="text-xs font-bold px-2.5 py-1 rounded-xl bg-emerald-500/20 text-emerald-300 border border-emerald-500/40">
|
||||
Completed
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-stone-500 font-medium flex items-center gap-1">
|
||||
<Lock className="w-3.5 h-3.5" />
|
||||
<span>Locked</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-end shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-6 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,588 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Compass,
|
||||
Crosshair,
|
||||
Sparkles,
|
||||
Waves,
|
||||
ShieldAlert,
|
||||
HelpCircle,
|
||||
AlertTriangle,
|
||||
Flame,
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
import { Course, Round, TrackingSettings } from '../types/golf';
|
||||
import { calculateRoundStats, formatToPar } from '../utils/golfStats';
|
||||
|
||||
interface AnalyticsProps {
|
||||
round: Round;
|
||||
course: Course;
|
||||
settings: TrackingSettings;
|
||||
}
|
||||
|
||||
export const Analytics: React.FC<AnalyticsProps> = ({
|
||||
round,
|
||||
course,
|
||||
settings,
|
||||
}) => {
|
||||
const stats = calculateRoundStats(round, course);
|
||||
|
||||
// Scoring distribution totals
|
||||
const totalScoredHoles = stats.holesPlayedCount || 1;
|
||||
const underParCount = stats.albatrossCount + stats.eagleCount + stats.birdieCount;
|
||||
|
||||
// FIR miss percentages
|
||||
const firMissTotal = stats.firLeft + stats.firRight + stats.firShort;
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16">
|
||||
{/* 1. Hero KPI Bar */}
|
||||
<div className="bg-gradient-to-br from-stone-900 to-stone-850 border border-stone-800 rounded-3xl p-6 shadow-xl relative overflow-hidden">
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-400 uppercase tracking-wider">
|
||||
<Award className="w-4 h-4" />
|
||||
<span>Round Performance Report</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-stone-400">{course.name}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-4 mt-2">
|
||||
<h2 className="text-4xl sm:text-5xl font-extrabold text-white font-mono tracking-tight">
|
||||
{stats.totalGross > 0 ? stats.totalGross : '—'}
|
||||
</h2>
|
||||
<span
|
||||
className={`text-2xl font-bold font-mono px-3 py-1 rounded-xl ${
|
||||
stats.toPar < 0
|
||||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/30'
|
||||
: stats.toPar > 0
|
||||
? 'bg-rose-500/20 text-rose-400 border border-rose-500/30'
|
||||
: 'bg-stone-800 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{stats.toParDisplay}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-400 mt-1">
|
||||
Front: {stats.frontGross} ({formatToPar(stats.frontToPar)}) · Back: {stats.backGross} ({formatToPar(stats.backToPar)})
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Metrics Cluster */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 w-full md:w-auto">
|
||||
<div className="bg-stone-800/60 border border-stone-700/60 rounded-2xl p-3 text-center min-w-[90px]">
|
||||
<span className="text-[10px] text-stone-400 uppercase font-semibold block">FIR</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.firPercentage}%
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500 font-mono">
|
||||
{stats.firHit}/{stats.firEligible}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/60 border border-stone-700/60 rounded-2xl p-3 text-center min-w-[90px]">
|
||||
<span className="text-[10px] text-stone-400 uppercase font-semibold block">GIR</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.girPercentage}%
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500 font-mono">
|
||||
{stats.girHit}/{stats.girEligible}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/60 border border-stone-700/60 rounded-2xl p-3 text-center min-w-[90px]">
|
||||
<span className="text-[10px] text-stone-400 uppercase font-semibold block">Putts / Hole</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.avgPuttsPerHole}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500 font-mono">{stats.totalPutts} total</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/60 border border-stone-700/60 rounded-2xl p-3 text-center min-w-[90px]">
|
||||
<span className="text-[10px] text-teal-400 uppercase font-semibold block">Foregreen</span>
|
||||
<span className="text-xl font-bold font-mono text-teal-300 mt-0.5 block">
|
||||
{stats.totalForegreenPutts}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500 font-mono">{stats.holesWithForegreenPutt} holes</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Score Distribution & Hole Par Averages */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Scoring Distribution */}
|
||||
<div className="lg:col-span-2 bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Flame className="w-4 h-4 text-amber-400" />
|
||||
<span>Score Distribution</span>
|
||||
</h3>
|
||||
<span className="text-xs text-stone-400 font-mono">
|
||||
{underParCount} under-par holes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Visual Stacked Progress Bar */}
|
||||
<div className="h-4 rounded-full overflow-hidden flex bg-stone-800 mb-6 shadow-inner">
|
||||
{stats.eagleCount > 0 && (
|
||||
<div
|
||||
style={{ width: `${(stats.eagleCount / totalScoredHoles) * 100}%` }}
|
||||
className="bg-amber-400 h-full"
|
||||
title={`Eagles: ${stats.eagleCount}`}
|
||||
/>
|
||||
)}
|
||||
{stats.birdieCount > 0 && (
|
||||
<div
|
||||
style={{ width: `${(stats.birdieCount / totalScoredHoles) * 100}%` }}
|
||||
className="bg-emerald-500 h-full"
|
||||
title={`Birdies: ${stats.birdieCount}`}
|
||||
/>
|
||||
)}
|
||||
{stats.parCount > 0 && (
|
||||
<div
|
||||
style={{ width: `${(stats.parCount / totalScoredHoles) * 100}%` }}
|
||||
className="bg-stone-600 h-full"
|
||||
title={`Pars: ${stats.parCount}`}
|
||||
/>
|
||||
)}
|
||||
{stats.bogeyCount > 0 && (
|
||||
<div
|
||||
style={{ width: `${(stats.bogeyCount / totalScoredHoles) * 100}%` }}
|
||||
className="bg-amber-600 h-full"
|
||||
title={`Bogeys: ${stats.bogeyCount}`}
|
||||
/>
|
||||
)}
|
||||
{stats.doubleBogeyCount + stats.triplePlusCount > 0 && (
|
||||
<div
|
||||
style={{
|
||||
width: `${
|
||||
((stats.doubleBogeyCount + stats.triplePlusCount) / totalScoredHoles) * 100
|
||||
}%`,
|
||||
}}
|
||||
className="bg-rose-600 h-full"
|
||||
title={`Double+: ${stats.doubleBogeyCount + stats.triplePlusCount}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Breakdown Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-5 gap-3">
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 text-center">
|
||||
<div className="text-[11px] font-semibold text-amber-400">Eagles</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">
|
||||
{stats.eagleCount}
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{Math.round((stats.eagleCount / totalScoredHoles) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 text-center">
|
||||
<div className="text-[11px] font-semibold text-emerald-400">Birdies</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">
|
||||
{stats.birdieCount}
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{Math.round((stats.birdieCount / totalScoredHoles) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 text-center">
|
||||
<div className="text-[11px] font-semibold text-stone-300">Pars</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">
|
||||
{stats.parCount}
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{Math.round((stats.parCount / totalScoredHoles) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 text-center">
|
||||
<div className="text-[11px] font-semibold text-amber-400">Bogeys</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">
|
||||
{stats.bogeyCount}
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{Math.round((stats.bogeyCount / totalScoredHoles) * 100)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 text-center">
|
||||
<div className="text-[11px] font-semibold text-rose-400">Double+</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">
|
||||
{stats.doubleBogeyCount + stats.triplePlusCount}
|
||||
</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{Math.round(
|
||||
((stats.doubleBogeyCount + stats.triplePlusCount) / totalScoredHoles) * 100
|
||||
)}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scoring by Par (Par 3 / 4 / 5 averages) */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white mb-4">Scoring by Par</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 rounded-2xl bg-stone-850 border border-stone-800">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-stone-300 block">Par 3 Average</span>
|
||||
<span className="text-[10px] text-stone-500">{stats.par3HolesCount} holes played</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xl font-bold font-mono text-white block">
|
||||
{stats.par3AvgScore > 0 ? stats.par3AvgScore : '—'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-mono font-semibold ${
|
||||
stats.par3AvgScore <= 3 ? 'text-emerald-400' : 'text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{stats.par3AvgScore > 0 ? formatToPar(Number((stats.par3AvgScore - 3).toFixed(2))) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-2xl bg-stone-850 border border-stone-800">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-stone-300 block">Par 4 Average</span>
|
||||
<span className="text-[10px] text-stone-500">{stats.par4HolesCount} holes played</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xl font-bold font-mono text-white block">
|
||||
{stats.par4AvgScore > 0 ? stats.par4AvgScore : '—'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-mono font-semibold ${
|
||||
stats.par4AvgScore <= 4 ? 'text-emerald-400' : 'text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{stats.par4AvgScore > 0 ? formatToPar(Number((stats.par4AvgScore - 4).toFixed(2))) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 rounded-2xl bg-stone-850 border border-stone-800">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-stone-300 block">Par 5 Average</span>
|
||||
<span className="text-[10px] text-stone-500">{stats.par5HolesCount} holes played</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-xl font-bold font-mono text-white block">
|
||||
{stats.par5AvgScore > 0 ? stats.par5AvgScore : '—'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-[11px] font-mono font-semibold ${
|
||||
stats.par5AvgScore <= 5 ? 'text-emerald-400' : 'text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{stats.par5AvgScore > 0 ? formatToPar(Number((stats.par5AvgScore - 5).toFixed(2))) : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Fairway Accuracy & Green in Regulation Deep Dive */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Fairway Accuracy Radar & Tendency */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Compass className="w-4 h-4 text-emerald-400" />
|
||||
<span>Fairway Tendency & Accuracy</span>
|
||||
</h3>
|
||||
<span className="text-xs font-mono font-bold text-emerald-400">
|
||||
{stats.firPercentage}% Hit Rate
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 mb-4 text-center">
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800">
|
||||
<div className="text-[11px] text-amber-400 font-semibold">Miss Left ⬅</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">{stats.firLeft}</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{stats.firEligible > 0 ? Math.round((stats.firLeft / stats.firEligible) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-950/30 p-3 rounded-2xl border border-emerald-800/40">
|
||||
<div className="text-[11px] text-emerald-400 font-semibold">Center Hit 🎯</div>
|
||||
<div className="text-2xl font-bold font-mono text-emerald-300 mt-1">{stats.firHit}</div>
|
||||
<div className="text-[10px] text-emerald-500 font-medium">{stats.firPercentage}%</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800">
|
||||
<div className="text-[11px] text-amber-400 font-semibold">Miss Right ➡</div>
|
||||
<div className="text-2xl font-bold font-mono text-white mt-1">{stats.firRight}</div>
|
||||
<div className="text-[10px] text-stone-500">
|
||||
{stats.firEligible > 0 ? Math.round((stats.firRight / stats.firEligible) * 100) : 0}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{firMissTotal > 0 && (
|
||||
<p className="text-xs text-stone-400 bg-stone-850 p-3 rounded-xl">
|
||||
<span className="font-semibold text-stone-200">Miss Bias: </span>
|
||||
{stats.firLeft > stats.firRight
|
||||
? 'Hook/Pull tendency (predominantly missing left of fairway).'
|
||||
: stats.firRight > stats.firLeft
|
||||
? 'Slice/Push tendency (predominantly missing right of fairway).'
|
||||
: 'Balanced dispersal across left and right misses.'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Green in Regulation (GIR) & Miss Locations */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-emerald-400" />
|
||||
<span>Green in Regulation (GIR)</span>
|
||||
</h3>
|
||||
<span className="text-xs font-mono font-bold text-emerald-400">
|
||||
{stats.girPercentage}% GIR
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 mb-4 text-center">
|
||||
<div className="bg-emerald-950/30 p-4 rounded-2xl border border-emerald-800/40">
|
||||
<div className="text-xs text-emerald-400 font-semibold">Greens Hit (On in Reg)</div>
|
||||
<div className="text-3xl font-extrabold font-mono text-emerald-300 mt-1">
|
||||
{stats.girHit} / {stats.girEligible}
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 mt-1">
|
||||
Avg {stats.puttsPerGIR} putts on GIR holes
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-4 rounded-2xl border border-stone-800">
|
||||
<div className="text-xs text-stone-300 font-semibold">Missed Greens</div>
|
||||
<div className="text-3xl font-extrabold font-mono text-stone-300 mt-1">
|
||||
{stats.girEligible - stats.girHit}
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 mt-1">
|
||||
Scrambled Par on {stats.scramblePercentage}%
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2 text-center text-xs">
|
||||
<div className="bg-stone-850 p-2 rounded-xl border border-stone-800">
|
||||
<span className="text-[10px] text-stone-500 block">Short ⬇</span>
|
||||
<span className="font-bold font-mono text-white">{stats.girMissShort}</span>
|
||||
</div>
|
||||
<div className="bg-stone-850 p-2 rounded-xl border border-stone-800">
|
||||
<span className="text-[10px] text-stone-500 block">Long ⬆</span>
|
||||
<span className="font-bold font-mono text-white">{stats.girMissLong}</span>
|
||||
</div>
|
||||
<div className="bg-stone-850 p-2 rounded-xl border border-stone-800">
|
||||
<span className="text-[10px] text-stone-500 block">Left ⬅</span>
|
||||
<span className="font-bold font-mono text-white">{stats.girMissLeft}</span>
|
||||
</div>
|
||||
<div className="bg-stone-850 p-2 rounded-xl border border-stone-800">
|
||||
<span className="text-[10px] text-stone-500 block">Right ➡</span>
|
||||
<span className="font-bold font-mono text-white">{stats.girMissRight}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Putting & Foregreen (Off-Green Putts) - Highlighted! */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm space-y-4">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2 border-b border-stone-800 pb-3">
|
||||
<div>
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-teal-400 inline-block"></span>
|
||||
<span>Putting & Foregreen / Off-Green Analytics</span>
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
Comprehensive analysis of strokes taken on the green and from the fringe (foregreen).
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-xs font-mono font-bold text-teal-400 bg-teal-950/40 px-3 py-1 rounded-xl border border-teal-800/40">
|
||||
{stats.totalPutts} Green Putts · {stats.totalForegreenPutts} Foregreen Putts
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||
<div className="bg-stone-850 p-4 rounded-2xl border border-stone-800 text-center">
|
||||
<span className="text-xs text-stone-400 font-semibold block">Total Official Putts</span>
|
||||
<span className="text-3xl font-extrabold font-mono text-white mt-1 block">
|
||||
{stats.totalPutts}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
Avg {stats.avgPuttsPerHole} per hole
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-4 rounded-2xl border border-stone-800 text-center">
|
||||
<span className="text-xs text-emerald-400 font-semibold block">1-Putt Conversion</span>
|
||||
<span className="text-3xl font-extrabold font-mono text-emerald-300 mt-1 block">
|
||||
{stats.onePuttCount}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
{totalScoredHoles > 0 ? Math.round((stats.onePuttCount / totalScoredHoles) * 100) : 0}% of holes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-4 rounded-2xl border border-stone-800 text-center">
|
||||
<span className="text-xs text-amber-400 font-semibold block">3-Putts or Worse</span>
|
||||
<span className="text-3xl font-extrabold font-mono text-amber-300 mt-1 block">
|
||||
{stats.threePuttPlusCount}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-500">
|
||||
{totalScoredHoles > 0 ? Math.round((stats.threePuttPlusCount / totalScoredHoles) * 100) : 0}% of holes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-teal-950/30 p-4 rounded-2xl border border-teal-800/40 text-center">
|
||||
<span className="text-xs text-teal-300 font-semibold block">Foregreen (Fringe) Putts</span>
|
||||
<span className="text-3xl font-extrabold font-mono text-teal-300 mt-1 block">
|
||||
{stats.totalForegreenPutts}
|
||||
</span>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
Used on {stats.holesWithForegreenPutt} holes ({stats.firstPuttFromFringeCount} first putts)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850/70 p-4 rounded-2xl border border-stone-800 text-xs text-stone-300 leading-relaxed">
|
||||
<span className="font-semibold text-white">Foregreen / Fringe Rule Distinction: </span>
|
||||
Per USGA and R&A guidelines, a stroke taken with a putter while the ball rests on the fringe or collar is not officially counted as a putt on the green. By tracking foregreen putts separately, you keep your official putting stats clean while measuring your true short-game putting skill and fringe recovery!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Bunkers & Sand Saves (Fairway vs Greenside) */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<span className="text-lg">🏖️</span>
|
||||
<span>Bunker Breakdown</span>
|
||||
</h3>
|
||||
<span className="text-xs font-mono font-bold text-amber-400">
|
||||
{stats.totalBunkerHoles} Holes in Sand
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3 text-center mb-4">
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800">
|
||||
<span className="text-[11px] text-stone-400 font-semibold block">Fairway Bunkers</span>
|
||||
<span className="text-2xl font-bold font-mono text-white mt-1 block">
|
||||
{stats.fairwayBunkersHit}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500">Tee/approach shot</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800">
|
||||
<span className="text-[11px] text-stone-400 font-semibold block">Greenside Bunkers</span>
|
||||
<span className="text-2xl font-bold font-mono text-white mt-1 block">
|
||||
{stats.greensideBunkersHit}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500">Around the green</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800">
|
||||
<span className="text-[11px] text-amber-400 font-semibold block">Both Hit</span>
|
||||
<span className="text-2xl font-bold font-mono text-amber-300 mt-1 block">
|
||||
{stats.bothBunkersHit}
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500">Multiple bunkers</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sand Save Rate */}
|
||||
<div className="p-3.5 bg-amber-950/20 border border-amber-800/40 rounded-2xl flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-amber-300 block">Sand Save Percentage</span>
|
||||
<span className="text-[11px] text-stone-400">Par or better made out of greenside bunker</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-2xl font-extrabold font-mono text-amber-300 block">
|
||||
{stats.sandSavePercentage}%
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-400 font-mono">
|
||||
{stats.sandSavesConverted}/{stats.sandSaveOpportunities} saved
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. Penalty Strokes & Specific Types Breakdown */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-base font-bold text-white flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||
<span>Penalty Strokes Breakdown</span>
|
||||
</h3>
|
||||
<span className="text-xs font-mono font-bold text-rose-400">
|
||||
+{stats.totalPenaltyStrokes} Strokes Lost
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{stats.totalPenaltyStrokes === 0 ? (
|
||||
<div className="p-8 text-center bg-stone-850 rounded-2xl border border-stone-800">
|
||||
<Sparkles className="w-8 h-8 text-emerald-400 mx-auto mb-2" />
|
||||
<div className="text-sm font-bold text-white">Clean Round!</div>
|
||||
<div className="text-xs text-stone-400 mt-0.5">
|
||||
Zero penalty strokes incurred across all played holes.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-center">
|
||||
<div className="bg-sky-950/30 border border-sky-800/40 p-3 rounded-2xl">
|
||||
<Waves className="w-4 h-4 text-sky-400 mx-auto mb-1" />
|
||||
<span className="text-[10px] text-sky-300 font-semibold block">Water Hazard</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.penaltiesByType.water}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-rose-950/30 border border-rose-800/40 p-3 rounded-2xl">
|
||||
<ShieldAlert className="w-4 h-4 text-rose-400 mx-auto mb-1" />
|
||||
<span className="text-[10px] text-rose-300 font-semibold block">Out of Bounds</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.penaltiesByType.ob}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-amber-950/30 border border-amber-800/40 p-3 rounded-2xl">
|
||||
<HelpCircle className="w-4 h-4 text-amber-400 mx-auto mb-1" />
|
||||
<span className="text-[10px] text-amber-300 font-semibold block">Lost Ball</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.penaltiesByType.lost_ball}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-950/30 border border-purple-800/40 p-3 rounded-2xl">
|
||||
<AlertTriangle className="w-4 h-4 text-purple-400 mx-auto mb-1" />
|
||||
<span className="text-[10px] text-purple-300 font-semibold block">Unplayable</span>
|
||||
<span className="text-xl font-bold font-mono text-white mt-0.5 block">
|
||||
{stats.penaltiesByType.unplayable + stats.penaltiesByType.other}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-stone-850 rounded-xl text-xs text-stone-400">
|
||||
Penalty strokes accounted for{' '}
|
||||
<span className="text-white font-semibold font-mono">
|
||||
{Math.round((stats.totalPenaltyStrokes / stats.totalGross) * 100)}%
|
||||
</span>{' '}
|
||||
of your total gross score. Eliminating these penalties would have lowered your score from{' '}
|
||||
<span className="font-bold text-white">{stats.totalGross}</span> to{' '}
|
||||
<span className="font-bold text-emerald-400">
|
||||
{stats.totalGross - stats.totalPenaltyStrokes} ({formatToPar(stats.toPar - stats.totalPenaltyStrokes)})
|
||||
</span>.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,524 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
X,
|
||||
Lock,
|
||||
Mail,
|
||||
User as UserIcon,
|
||||
Shield,
|
||||
Check,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Globe,
|
||||
Sparkles,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { TrackingSettings } from '../types/golf';
|
||||
|
||||
interface AuthModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSettingsSynced?: (settings: TrackingSettings) => void;
|
||||
onSyncLocalRoundsPrompt?: () => void;
|
||||
}
|
||||
|
||||
export const AuthModal: React.FC<AuthModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSettingsSynced,
|
||||
onSyncLocalRoundsPrompt,
|
||||
}) => {
|
||||
const { login, register, loginWithSSO, error, clearError } = useAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'signin' | 'register' | 'sso'>('signin');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
// Sign In form
|
||||
const [signInIdentifier, setSignInIdentifier] = useState('');
|
||||
const [signInPassword, setSignInPassword] = useState('');
|
||||
|
||||
// Register form
|
||||
const [regEmail, setRegEmail] = useState('');
|
||||
const [regUsername, setRegUsername] = useState('');
|
||||
const [regPassword, setRegPassword] = useState('');
|
||||
const [regName, setRegName] = useState('');
|
||||
const [regHandicap, setRegHandicap] = useState('14.0');
|
||||
const [regHomeClub, setRegHomeClub] = useState('');
|
||||
const [regIsPublic, setRegIsPublic] = useState(true);
|
||||
|
||||
// Authentik / SSO custom form
|
||||
const [authentikUrl, setAuthentikUrl] = useState('https://authentik.company.local');
|
||||
const [authentikUsername, setAuthentikUsername] = useState('');
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleSignIn = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
clearError();
|
||||
|
||||
if (!signInIdentifier || !signInPassword) {
|
||||
setFormError('Please enter your email/username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const settings = await login(signInIdentifier, signInPassword);
|
||||
if (settings && onSettingsSynced) onSettingsSynced(settings);
|
||||
if (onSyncLocalRoundsPrompt) onSyncLocalRoundsPrompt();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Login failed. Please verify credentials.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setFormError(null);
|
||||
clearError();
|
||||
|
||||
if (!regEmail || !regUsername || !regPassword) {
|
||||
setFormError('Please fill in email, username, and password');
|
||||
return;
|
||||
}
|
||||
|
||||
if (regPassword.length < 6) {
|
||||
setFormError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const hcp = parseFloat(regHandicap) || 15.0;
|
||||
const settings = await register({
|
||||
email: regEmail,
|
||||
username: regUsername,
|
||||
password: regPassword,
|
||||
name: regName || regUsername,
|
||||
handicapIndex: hcp,
|
||||
homeClub: regHomeClub || 'Local Course',
|
||||
isPublic: regIsPublic,
|
||||
});
|
||||
|
||||
if (settings && onSettingsSynced) onSettingsSynced(settings);
|
||||
if (onSyncLocalRoundsPrompt) onSyncLocalRoundsPrompt();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || 'Registration failed');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSSOLogin = async (provider: 'google' | 'authentik' | 'github') => {
|
||||
setFormError(null);
|
||||
clearError();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
let customData = {};
|
||||
if (provider === 'authentik') {
|
||||
customData = {
|
||||
authentikServerUrl: authentikUrl,
|
||||
username: authentikUsername || undefined,
|
||||
name: authentikUsername ? `${authentikUsername} (Authentik SSO)` : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
const settings = await loginWithSSO(provider, customData);
|
||||
if (settings && onSettingsSynced) onSettingsSynced(settings);
|
||||
if (onSyncLocalRoundsPrompt) onSyncLocalRoundsPrompt();
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message || `${provider} login failed`);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuickDemo = async () => {
|
||||
setSignInIdentifier('demogolfer');
|
||||
setSignInPassword('golf1234');
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const settings = await login('demogolfer', 'golf1234');
|
||||
if (settings && onSettingsSynced) onSettingsSynced(settings);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setFormError(err.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fadeIn">
|
||||
<div className="relative w-full max-w-lg bg-stone-900 border border-stone-800 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[90vh]">
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-stone-800 bg-stone-950/60">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-emerald-500/10 border border-emerald-500/20 flex items-center justify-center text-emerald-400">
|
||||
<Lock className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-100">PinPoint Golfer Account</h2>
|
||||
<p className="text-xs text-stone-400">Sync rounds, custom courses & compare public stats</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-stone-400 hover:text-stone-200 hover:bg-stone-800/80 rounded-xl transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Switcher */}
|
||||
<div className="flex border-b border-stone-800 bg-stone-950/40 px-6 pt-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('signin');
|
||||
setFormError(null);
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-semibold border-b-2 transition ${
|
||||
activeTab === 'signin'
|
||||
? 'border-emerald-500 text-emerald-400'
|
||||
: 'border-transparent text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('register');
|
||||
setFormError(null);
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-semibold border-b-2 transition ${
|
||||
activeTab === 'register'
|
||||
? 'border-emerald-500 text-emerald-400'
|
||||
: 'border-transparent text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
Register
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setActiveTab('sso');
|
||||
setFormError(null);
|
||||
}}
|
||||
className={`flex-1 py-3 text-sm font-semibold border-b-2 transition ${
|
||||
activeTab === 'sso'
|
||||
? 'border-emerald-500 text-emerald-400'
|
||||
: 'border-transparent text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
OAuth / SSO
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-4">
|
||||
{(formError || error) && (
|
||||
<div className="p-3.5 bg-rose-500/10 border border-rose-500/20 rounded-2xl flex items-start gap-3 text-rose-400 text-xs">
|
||||
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
||||
<span>{formError || error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 1: SIGN IN */}
|
||||
{activeTab === 'signin' && (
|
||||
<form onSubmit={handleSignIn} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1.5">
|
||||
Email or Username
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="w-4 h-4 text-stone-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={signInIdentifier}
|
||||
onChange={(e) => setSignInIdentifier(e.target.value)}
|
||||
placeholder="golfer@domain.com or demogolfer"
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 focus:outline-none focus:border-emerald-500 text-sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1.5">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="w-4 h-4 text-stone-500 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
value={signInPassword}
|
||||
onChange={(e) => setSignInPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full pl-10 pr-10 py-2.5 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 focus:outline-none focus:border-emerald-500 text-sm"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-stone-500 hover:text-stone-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg shadow-emerald-900/30 transition flex items-center justify-center gap-2 text-sm mt-2"
|
||||
>
|
||||
{submitting ? 'Authenticating...' : 'Sign In to Account'}
|
||||
</button>
|
||||
|
||||
<div className="pt-3 border-t border-stone-800/80">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleQuickDemo}
|
||||
className="w-full py-2.5 bg-stone-800/60 hover:bg-stone-800 border border-stone-700/60 rounded-2xl text-stone-300 text-xs font-medium flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Sparkles className="w-3.5 h-3.5 text-amber-400" />
|
||||
One-Click Test Login as Demo Golfer (demogolfer / golf1234)
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* TAB 2: REGISTER */}
|
||||
{activeTab === 'register' && (
|
||||
<form onSubmit={handleRegister} className="space-y-3.5">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Email *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
required
|
||||
value={regEmail}
|
||||
onChange={(e) => setRegEmail(e.target.value)}
|
||||
placeholder="golfer@email.com"
|
||||
className="w-full px-3.5 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Username *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={regUsername}
|
||||
onChange={(e) => setRegUsername(e.target.value)}
|
||||
placeholder="swing_master"
|
||||
className="w-full px-3.5 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Full Name / Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regName}
|
||||
onChange={(e) => setRegName(e.target.value)}
|
||||
placeholder="e.g. Jiří Stach"
|
||||
className="w-full px-3.5 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Password * (min. 6 characters)
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
required
|
||||
minLength={6}
|
||||
value={regPassword}
|
||||
onChange={(e) => setRegPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3.5 pr-10 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-stone-500 hover:text-stone-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Handicap Index (WHS)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="-10"
|
||||
max="54"
|
||||
value={regHandicap}
|
||||
onChange={(e) => setRegHandicap(e.target.value)}
|
||||
placeholder="14.2"
|
||||
className="w-full px-3.5 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-stone-400 mb-1">
|
||||
Home Golf Club
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={regHomeClub}
|
||||
onChange={(e) => setRegHomeClub(e.target.value)}
|
||||
placeholder="Pebble Cove Links"
|
||||
className="w-full px-3.5 py-2 bg-stone-950 border border-stone-800 rounded-xl text-stone-100 placeholder:text-stone-600 text-sm focus:border-emerald-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-3 p-3 bg-stone-950/80 border border-stone-800 rounded-xl cursor-pointer hover:border-emerald-500/50 transition">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={regIsPublic}
|
||||
onChange={(e) => setRegIsPublic(e.target.checked)}
|
||||
className="w-4 h-4 text-emerald-500 rounded bg-stone-900 border-stone-700 focus:ring-emerald-500"
|
||||
/>
|
||||
<div className="text-xs">
|
||||
<p className="font-semibold text-stone-200">Share stats publicly for Community Leaderboard</p>
|
||||
<p className="text-stone-500">Compare your FIR, GIR, & Putting stats with other golfers worldwide</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="w-full py-3 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white font-bold rounded-2xl shadow-lg shadow-emerald-900/30 transition flex items-center justify-center gap-2 text-sm"
|
||||
>
|
||||
{submitting ? 'Creating Account...' : 'Create Account & Sync'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* TAB 3: OAUTH / SSO */}
|
||||
{activeTab === 'sso' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-stone-400 leading-relaxed">
|
||||
Log in via your favorite identity provider or homelab Single Sign-On (Authentik / OIDC).
|
||||
</p>
|
||||
|
||||
{/* Google Login Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSSOLogin('google')}
|
||||
disabled={submitting}
|
||||
className="w-full py-3 px-4 bg-stone-950 hover:bg-stone-800/80 border border-stone-700/80 rounded-2xl text-stone-200 font-semibold text-sm flex items-center justify-center gap-3 shadow transition active:scale-[0.99]"
|
||||
>
|
||||
<svg className="w-5 h-5 shrink-0" viewBox="0 0 24 24">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.06H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.94l2.85-2.22.81-.63z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.06l3.66 2.84c.87-2.6 3.3-4.52 6.16-4.52z"
|
||||
/>
|
||||
</svg>
|
||||
Continue with Google
|
||||
</button>
|
||||
|
||||
{/* Authentik / OIDC Box */}
|
||||
<div className="p-4 bg-stone-950 border border-stone-800 rounded-2xl space-y-3">
|
||||
<div className="flex items-center gap-2 text-stone-200 font-semibold text-sm">
|
||||
<Shield className="w-4 h-4 text-orange-400" />
|
||||
<span>Authentik (OIDC SSO)</span>
|
||||
</div>
|
||||
<p className="text-xs text-stone-500">
|
||||
Connect via self-hosted Authentik server or corporate Identity Provider:
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-3.5 h-3.5 text-stone-500 shrink-0" />
|
||||
<input
|
||||
type="url"
|
||||
value={authentikUrl}
|
||||
onChange={(e) => setAuthentikUrl(e.target.value)}
|
||||
placeholder="https://authentik.company.local"
|
||||
className="flex-1 px-3 py-1.5 bg-stone-900 border border-stone-800 rounded-xl text-stone-200 text-xs focus:border-orange-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-3.5 h-3.5 text-stone-500 shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
value={authentikUsername}
|
||||
onChange={(e) => setAuthentikUsername(e.target.value)}
|
||||
placeholder="Authentik Username (e.g. jiri_s)"
|
||||
className="flex-1 px-3 py-1.5 bg-stone-900 border border-stone-800 rounded-xl text-stone-200 text-xs focus:border-orange-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSSOLogin('authentik')}
|
||||
disabled={submitting}
|
||||
className="w-full py-2.5 bg-orange-600/90 hover:bg-orange-500 text-white font-semibold rounded-xl text-xs flex items-center justify-center gap-2 transition"
|
||||
>
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
Sign In with Authentik
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GitHub OAuth Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSSOLogin('github')}
|
||||
disabled={submitting}
|
||||
className="w-full py-3 px-4 bg-stone-950 hover:bg-stone-800/80 border border-stone-700/80 rounded-2xl text-stone-200 font-semibold text-sm flex items-center justify-center gap-3 shadow transition active:scale-[0.99]"
|
||||
>
|
||||
<svg className="w-5 h-5 fill-current text-white shrink-0" viewBox="0 0 24 24">
|
||||
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z" />
|
||||
</svg>
|
||||
Continue with GitHub
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal Footer */}
|
||||
<div className="px-6 py-4 border-t border-stone-800 bg-stone-950/80 flex items-center justify-between text-xs text-stone-500">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Globe className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span>Encrypted sessions & GDPR-ready stats</span>
|
||||
</div>
|
||||
<span className="font-mono text-stone-400">PinPoint Cloud v1</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,458 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Users,
|
||||
Trophy,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Shield,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Medal,
|
||||
Sparkles,
|
||||
Target,
|
||||
Flag,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { communityApi } from '../services/api';
|
||||
import { PublicGolferStats, CommunityComparisonData, ComparisonMetric } from '../types/auth';
|
||||
|
||||
interface CommunityComparisonModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onOpenAuthModal?: () => void;
|
||||
}
|
||||
|
||||
export const CommunityComparisonModal: React.FC<CommunityComparisonModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onOpenAuthModal,
|
||||
}) => {
|
||||
const { user, updateProfile } = useAuth();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'compare' | 'leaderboard'>('compare');
|
||||
const [bracketFilter, setBracketFilter] = useState<'all' | 'scratch' | 'mid' | 'high'>('all');
|
||||
const [data, setData] = useState<CommunityComparisonData | null>(null);
|
||||
const [leaderboard, setLeaderboard] = useState<PublicGolferStats[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [updatingPrivacy, setUpdatingPrivacy] = useState(false);
|
||||
|
||||
const fetchData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const [compRes, leadRes] = await Promise.all([
|
||||
communityApi.getComparison(),
|
||||
communityApi.getLeaderboard(),
|
||||
]);
|
||||
setData(compRes);
|
||||
setLeaderboard(leadRes.golfers);
|
||||
} catch (err) {
|
||||
console.error('Failed to load community data:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchData();
|
||||
}
|
||||
}, [isOpen, user?.id]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleTogglePrivacy = async () => {
|
||||
if (!user) {
|
||||
if (onOpenAuthModal) onOpenAuthModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setUpdatingPrivacy(true);
|
||||
const newStatus = !user.isPublic;
|
||||
await updateProfile({ isPublic: newStatus });
|
||||
await fetchData();
|
||||
} catch (err) {
|
||||
console.error('Failed to update privacy:', err);
|
||||
} finally {
|
||||
setUpdatingPrivacy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredGolfers = leaderboard.filter((g) => {
|
||||
if (bracketFilter === 'scratch') return g.handicapIndex < 5;
|
||||
if (bracketFilter === 'mid') return g.handicapIndex >= 5 && g.handicapIndex <= 15;
|
||||
if (bracketFilter === 'high') return g.handicapIndex > 15;
|
||||
return true;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-md animate-fadeIn">
|
||||
<div className="relative w-full max-w-4xl bg-stone-900 border border-stone-800 rounded-3xl shadow-2xl overflow-hidden flex flex-col max-h-[92vh]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-5 border-b border-stone-800 bg-stone-950/70">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-2xl bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-stone-100 flex items-center gap-2">
|
||||
Community Benchmark & Leaderboard
|
||||
<span className="text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded-full bg-emerald-500/20 text-emerald-400 border border-emerald-500/30">
|
||||
Live Global Stats
|
||||
</span>
|
||||
</h2>
|
||||
<p className="text-xs text-stone-400">
|
||||
Compare your ball-striking, putting, and scoring to golfers worldwide
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 text-stone-400 hover:text-stone-200 hover:bg-stone-800/80 rounded-xl transition"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* View Switcher & Privacy Bar */}
|
||||
<div className="px-6 py-3 bg-stone-950/40 border-b border-stone-800 flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<div className="flex bg-stone-950 p-1 rounded-2xl border border-stone-800 w-full sm:w-auto">
|
||||
<button
|
||||
onClick={() => setActiveTab('compare')}
|
||||
className={`flex-1 sm:flex-initial px-4 py-2 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
||||
activeTab === 'compare'
|
||||
? 'bg-emerald-600 text-white shadow-md'
|
||||
: 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-3.5 h-3.5" />
|
||||
Benchmark Comparison
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('leaderboard')}
|
||||
className={`flex-1 sm:flex-initial px-4 py-2 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition ${
|
||||
activeTab === 'leaderboard'
|
||||
? 'bg-emerald-600 text-white shadow-md'
|
||||
: 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
Public Leaderboard ({leaderboard.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Privacy Status & Sync */}
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto justify-between sm:justify-end">
|
||||
{user ? (
|
||||
<button
|
||||
onClick={handleTogglePrivacy}
|
||||
disabled={updatingPrivacy}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-medium border flex items-center gap-2 transition ${
|
||||
user.isPublic
|
||||
? 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400 hover:bg-emerald-500/20'
|
||||
: 'bg-stone-800/80 border-stone-700 text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
{user.isPublic ? <Eye className="w-3.5 h-3.5" /> : <EyeOff className="w-3.5 h-3.5" />}
|
||||
<span>
|
||||
Stats: <strong className="font-bold">{user.isPublic ? 'Public' : 'Private'}</strong>
|
||||
</span>
|
||||
<span className="text-[10px] text-stone-500">(Click to change)</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenAuthModal}
|
||||
className="px-3 py-1.5 bg-stone-800 hover:bg-stone-700 border border-stone-700 rounded-xl text-xs text-stone-300 font-medium flex items-center gap-1.5 transition"
|
||||
>
|
||||
<Sparkles className="w-3 h-3 text-amber-400" />
|
||||
Sign in to share your stats
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={fetchData}
|
||||
title="Refresh Stats"
|
||||
className="p-1.5 text-stone-400 hover:text-stone-200 hover:bg-stone-800 rounded-lg transition"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 overflow-y-auto space-y-6">
|
||||
{loading ? (
|
||||
<div className="py-20 flex flex-col items-center justify-center space-y-3">
|
||||
<div className="w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-xs text-stone-400">Aggregating community scorecard database...</p>
|
||||
</div>
|
||||
) : activeTab === 'compare' && data ? (
|
||||
<div className="space-y-6">
|
||||
{/* Executive Summary Cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Your Scoring Avg</p>
|
||||
<p className="text-2xl font-black font-mono text-stone-100 mt-1">{data.userStats.scoringAverage}</p>
|
||||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.scoringAverage}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Fairway Accuracy</p>
|
||||
<p className="text-2xl font-black font-mono text-emerald-400 mt-1">
|
||||
{data.userStats.firPercentage}%
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.firPercentage}%</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Greens in Reg (GIR)</p>
|
||||
<p className="text-2xl font-black font-mono text-sky-400 mt-1">{data.userStats.girPercentage}%</p>
|
||||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.girPercentage}%</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-stone-950/80 border border-stone-800 rounded-2xl">
|
||||
<p className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider">Putts Per Round</p>
|
||||
<p className="text-2xl font-black font-mono text-amber-400 mt-1">{data.userStats.puttsPerRound}</p>
|
||||
<p className="text-[11px] text-stone-500 mt-0.5">
|
||||
Comm Avg: <span className="font-mono text-stone-300">{data.communityAverage.puttsPerRound}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Benchmark Breakdown Table */}
|
||||
<div className="bg-stone-950/60 border border-stone-800 rounded-2xl p-5 space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2 border-b border-stone-800/80 pb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-stone-200">Head-to-Head Performance Matrix</h3>
|
||||
<p className="text-xs text-stone-400">
|
||||
Comparing against global database ({data.communityAverage.totalPublicGolfers} public golfers, {data.communityAverage.totalPublicRounds} rounds)
|
||||
</p>
|
||||
</div>
|
||||
<span className="px-2.5 py-1 bg-stone-800/80 border border-stone-700 rounded-xl text-[11px] text-stone-300 font-medium">
|
||||
Handicap Bracket: <strong>{data.handicapBracket}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{data.metrics.map((metric: ComparisonMetric) => {
|
||||
const isPositive = metric.lowerIsBetter
|
||||
? metric.userValue <= metric.communityAverage
|
||||
: metric.userValue >= metric.communityAverage;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={metric.key}
|
||||
className="p-3.5 bg-stone-900/60 border border-stone-800/70 rounded-xl flex flex-col md:flex-row md:items-center justify-between gap-3 hover:border-stone-700/80 transition"
|
||||
>
|
||||
<div className="md:w-1/3">
|
||||
<p className="text-xs font-bold text-stone-200">{metric.label}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="text-[11px] text-stone-400">
|
||||
Bracket Avg: <strong className="font-mono text-stone-300">{metric.bracketAverage} {metric.unit}</strong>
|
||||
</span>
|
||||
<span className="text-stone-600">•</span>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
Percentile: <strong className="font-mono text-emerald-400">{metric.percentileRank}%</strong>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comparative Bar Visual */}
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<div className="flex justify-between text-[11px]">
|
||||
<span className="text-stone-400">
|
||||
You: <strong className="text-stone-100 font-mono">{metric.userValue} {metric.unit}</strong>
|
||||
</span>
|
||||
<span className="text-stone-400">
|
||||
Community Avg: <strong className="text-stone-300 font-mono">{metric.communityAverage} {metric.unit}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-2.5 bg-stone-800 rounded-full overflow-hidden relative">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${
|
||||
isPositive ? 'bg-emerald-500' : 'bg-amber-500'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, Math.max(10, metric.percentileRank))}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="md:w-32 flex md:justify-end items-center">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-xl text-[11px] font-bold flex items-center gap-1 border ${
|
||||
isPositive
|
||||
? 'bg-emerald-500/10 border-emerald-500/20 text-emerald-400'
|
||||
: 'bg-amber-500/10 border-amber-500/20 text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{isPositive ? (
|
||||
<TrendingUp className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<TrendingDown className="w-3.5 h-3.5" />
|
||||
)}
|
||||
{isPositive ? 'Top Tier' : 'Growth Area'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Coaching Insights */}
|
||||
<div className="p-4 bg-stone-950/60 border border-stone-800 rounded-2xl flex items-start gap-3">
|
||||
<Target className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />
|
||||
<div className="text-xs space-y-1">
|
||||
<p className="font-bold text-stone-200">Automated Coaching Insight from Community Benchmarks</p>
|
||||
<p className="text-stone-400 leading-relaxed">
|
||||
Based on players in your handicap bracket ({data.handicapBracket}), improving your{' '}
|
||||
<strong className="text-stone-200">Greens in Regulation</strong> and{' '}
|
||||
<strong className="text-stone-200">Scrambling conversion</strong> represents the highest mathematical
|
||||
leverage for cutting 2 to 4 strokes off your 18-hole total.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeTab === 'leaderboard' ? (
|
||||
<div className="space-y-4">
|
||||
{/* Bracket Filter Tabs */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{[
|
||||
{ key: 'all', label: 'All Public Golfers' },
|
||||
{ key: 'scratch', label: 'Scratch (< 5.0 Hcp)' },
|
||||
{ key: 'mid', label: 'Mid-Cap (5–15 Hcp)' },
|
||||
{ key: 'high', label: 'High-Cap (15+ Hcp)' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setBracketFilter(tab.key as any)}
|
||||
className={`px-3 py-1.5 rounded-xl text-xs font-semibold border transition ${
|
||||
bracketFilter === tab.key
|
||||
? 'bg-emerald-600 border-emerald-500 text-white'
|
||||
: 'bg-stone-950 border-stone-800 text-stone-400 hover:text-stone-200 hover:border-stone-700'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Leaderboard Table */}
|
||||
<div className="overflow-x-auto border border-stone-800 rounded-2xl bg-stone-950/60">
|
||||
<table className="w-full text-left border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="border-b border-stone-800 bg-stone-900/60 text-stone-400 uppercase tracking-wider text-[10px]">
|
||||
<th className="py-3 px-4">Rank</th>
|
||||
<th className="py-3 px-4">Golfer</th>
|
||||
<th className="py-3 px-3 text-center">Handicap</th>
|
||||
<th className="py-3 px-3 text-center">Rounds</th>
|
||||
<th className="py-3 px-3 text-center">Scoring Avg</th>
|
||||
<th className="py-3 px-3 text-center">Best Gross</th>
|
||||
<th className="py-3 px-3 text-center">FIR %</th>
|
||||
<th className="py-3 px-3 text-center">GIR %</th>
|
||||
<th className="py-3 px-3 text-center">Putts/Rnd</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-800/60 text-stone-200">
|
||||
{filteredGolfers.map((golfer, idx) => {
|
||||
const isCurrentUser = user && (user.id === golfer.id || user.username === golfer.username);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={golfer.id}
|
||||
className={`hover:bg-stone-850/50 transition ${
|
||||
isCurrentUser ? 'bg-emerald-500/10 font-semibold' : ''
|
||||
}`}
|
||||
>
|
||||
<td className="py-3 px-4 font-mono font-bold text-stone-400">
|
||||
{idx === 0 ? (
|
||||
<span className="flex items-center gap-1 text-amber-400">
|
||||
<Medal className="w-3.5 h-3.5" /> 1
|
||||
</span>
|
||||
) : idx === 1 ? (
|
||||
<span className="flex items-center gap-1 text-slate-300">
|
||||
<Medal className="w-3.5 h-3.5" /> 2
|
||||
</span>
|
||||
) : idx === 2 ? (
|
||||
<span className="flex items-center gap-1 text-amber-600">
|
||||
<Medal className="w-3.5 h-3.5" /> 3
|
||||
</span>
|
||||
) : (
|
||||
`#${idx + 1}`
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{golfer.avatarUrl ? (
|
||||
<img
|
||||
src={golfer.avatarUrl}
|
||||
alt={golfer.name}
|
||||
className="w-6 h-6 rounded-full object-cover border border-stone-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded-full bg-stone-800 border border-stone-700 flex items-center justify-center text-[10px] font-bold text-stone-300">
|
||||
{golfer.name.charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-bold text-stone-100 flex items-center gap-1.5">
|
||||
{golfer.name}
|
||||
{isCurrentUser && (
|
||||
<span className="text-[9px] bg-emerald-500 text-stone-950 font-black px-1.5 py-0.2 rounded-md">
|
||||
YOU
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-[10px] text-stone-500">{golfer.homeClub}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono font-bold text-emerald-400">
|
||||
{golfer.handicapIndex > 0 ? golfer.handicapIndex : 'Scratch'}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-stone-400">{golfer.roundsCount}</td>
|
||||
<td className="py-3 px-3 text-center font-mono font-bold text-stone-100">
|
||||
{golfer.scoringAverage}
|
||||
</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.bestGross}</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.firPercentage}%</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-stone-300">{golfer.girPercentage}%</td>
|
||||
<td className="py-3 px-3 text-center font-mono text-amber-400 font-semibold">
|
||||
{golfer.puttsPerRound}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-stone-800 bg-stone-950/80 flex items-center justify-between text-xs text-stone-500">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-3.5 h-3.5 text-emerald-500" />
|
||||
<span>Anonymous peer comparison enabled. Only public scorecards contribute to leaderboard.</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-1.5 bg-stone-800 hover:bg-stone-700 text-stone-200 rounded-xl text-xs font-semibold transition"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, Copy, RotateCcw, Plus, Trash2, Check, MapPin, Flag } from 'lucide-react';
|
||||
import { Course, Hole } from '../types/golf';
|
||||
|
||||
interface CourseEditorModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
baseCourse?: Course | null; // If cloning/modifying an existing course
|
||||
onSaveCourse: (savedCourse: Course) => void;
|
||||
}
|
||||
|
||||
export const CourseEditorModal: React.FC<CourseEditorModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
baseCourse,
|
||||
onSaveCourse,
|
||||
}) => {
|
||||
const [courseName, setCourseName] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [teeName, setTeeName] = useState('Tournament Blue');
|
||||
const [holesCount, setHolesCount] = useState<18 | 9>(18);
|
||||
const [courseRating, setCourseRating] = useState<number>(72.0);
|
||||
const [slopeRating, setSlopeRating] = useState<number>(128);
|
||||
const [holes, setHoles] = useState<Hole[]>([]);
|
||||
|
||||
// Initialize or re-populate when modal opens or baseCourse changes
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
if (baseCourse) {
|
||||
// Cloning / Modifying existing course
|
||||
setCourseName(`${baseCourse.name} (Custom)`);
|
||||
setLocation(baseCourse.location || 'Local Golf Club');
|
||||
setTeeName(baseCourse.tee || 'Tournament Blue');
|
||||
const count = baseCourse.holes.length === 9 ? 9 : 18;
|
||||
setHolesCount(count);
|
||||
setCourseRating(baseCourse.rating || 72.0);
|
||||
setSlopeRating(baseCourse.slope || 128);
|
||||
// Deep clone holes
|
||||
setHoles(baseCourse.holes.map((h) => ({ ...h })));
|
||||
} else {
|
||||
// New course from scratch
|
||||
setCourseName('My Home Golf Club');
|
||||
setLocation('Local Course');
|
||||
setTeeName('Member White');
|
||||
setHolesCount(18);
|
||||
setCourseRating(71.5);
|
||||
setSlopeRating(125);
|
||||
|
||||
const defaultPars = [4, 4, 3, 5, 4, 4, 3, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 5];
|
||||
const defaultYards = [380, 405, 170, 515, 390, 420, 155, 435, 505, 385, 415, 180, 525, 400, 365, 190, 425, 530];
|
||||
|
||||
const initialHoles: Hole[] = [];
|
||||
for (let i = 1; i <= 18; i++) {
|
||||
initialHoles.push({
|
||||
holeNumber: i,
|
||||
par: defaultPars[i - 1],
|
||||
yardage: defaultYards[i - 1],
|
||||
handicapIndex: i,
|
||||
});
|
||||
}
|
||||
setHoles(initialHoles);
|
||||
}
|
||||
}, [isOpen, baseCourse]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleHoleChange = (index: number, field: keyof Hole, value: number) => {
|
||||
setHoles((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[index] = {
|
||||
...copy[index],
|
||||
[field]: value,
|
||||
};
|
||||
return copy;
|
||||
});
|
||||
};
|
||||
|
||||
const handleHolesCountChange = (count: 18 | 9) => {
|
||||
setHolesCount(count);
|
||||
if (count === 9 && holes.length > 9) {
|
||||
setHoles(holes.slice(0, 9));
|
||||
} else if (count === 18 && holes.length === 9) {
|
||||
const extraHoles: Hole[] = [];
|
||||
for (let i = 10; i <= 18; i++) {
|
||||
extraHoles.push({
|
||||
holeNumber: i,
|
||||
par: 4,
|
||||
yardage: 400,
|
||||
handicapIndex: i,
|
||||
});
|
||||
}
|
||||
setHoles([...holes, ...extraHoles]);
|
||||
}
|
||||
};
|
||||
|
||||
const totalPar = holes.reduce((sum, h) => sum + (h.par || 0), 0);
|
||||
const totalYardage = holes.reduce((sum, h) => sum + (h.yardage || 0), 0);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!courseName.trim()) return;
|
||||
|
||||
const savedCourse: Course = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: courseName.trim(),
|
||||
location: location.trim() || 'Custom Course',
|
||||
tee: teeName.trim() || 'Custom Tee',
|
||||
parTotal: totalPar,
|
||||
rating: courseRating,
|
||||
slope: slopeRating,
|
||||
holes: holes.slice(0, holesCount),
|
||||
isCustom: true,
|
||||
};
|
||||
|
||||
onSaveCourse(savedCourse);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-3xl w-full p-5 sm:p-6 shadow-2xl relative max-h-[92vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-stone-800 shrink-0">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-400 uppercase tracking-wider">
|
||||
<Flag className="w-4 h-4" />
|
||||
<span>{baseCourse ? 'Duplicate & Modify Course' : 'Create Custom Golf Course'}</span>
|
||||
</div>
|
||||
<h3 className="text-xl font-extrabold text-white mt-0.5">
|
||||
{baseCourse ? `Edit: ${baseCourse.name}` : 'New Course Builder'}
|
||||
</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
Customize course details, tee boxes, and individual hole pars & yardages
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Form Body */}
|
||||
<div className="space-y-6 my-4 overflow-y-auto flex-1 pr-1.5">
|
||||
{/* 1. General Course Info */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 bg-stone-850 p-4 rounded-2xl border border-stone-800">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Course Name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={courseName}
|
||||
onChange={(e) => setCourseName(e.target.value)}
|
||||
placeholder="e.g. Pinehurst No. 2"
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500 font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Location (City, State / Region)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={location}
|
||||
onChange={(e) => setLocation(e.target.value)}
|
||||
placeholder="e.g. Pinehurst, NC"
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Tee Box Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={teeName}
|
||||
onChange={(e) => setTeeName(e.target.value)}
|
||||
placeholder="e.g. Tournament Blue, Member White"
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-sm text-white focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Holes Configuration
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHolesCountChange(18)}
|
||||
className={`py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
holesCount === 18
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
18 Holes
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleHolesCountChange(9)}
|
||||
className={`py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
holesCount === 9
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
9 Holes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Course Rating (e.g. 72.4)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
step="0.1"
|
||||
value={courseRating}
|
||||
onChange={(e) => setCourseRating(Number(e.target.value))}
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1">
|
||||
Slope Rating (e.g. 130)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={slopeRating}
|
||||
onChange={(e) => setSlopeRating(Number(e.target.value))}
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-sm text-white font-mono focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Course Totals Summary Banner */}
|
||||
<div className="flex items-center justify-between bg-stone-800/80 p-3.5 rounded-2xl border border-stone-700 text-xs font-mono">
|
||||
<span className="text-stone-300 font-sans font-semibold">Course Totals:</span>
|
||||
<div className="flex items-center gap-4">
|
||||
<span className="text-white">
|
||||
Total Par: <strong className="text-emerald-400 text-sm">{totalPar}</strong>
|
||||
</span>
|
||||
<span className="text-stone-500">·</span>
|
||||
<span className="text-white">
|
||||
Total Yards: <strong className="text-emerald-400 text-sm">{totalYardage}</strong> yds
|
||||
</span>
|
||||
<span className="text-stone-500">·</span>
|
||||
<span className="text-stone-400">{holesCount} Holes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Hole-by-Hole Grid Editor */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-stone-300">
|
||||
Hole-by-Hole Specifications:
|
||||
</label>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
Click any value to modify Par, Yardage, or Handicap
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-2xl border border-stone-800">
|
||||
<table className="w-full text-xs font-mono tabular-nums text-center">
|
||||
<thead>
|
||||
<tr className="bg-stone-950 text-stone-400 border-b border-stone-800">
|
||||
<th className="py-2.5 px-3 text-left font-sans font-semibold text-stone-300 w-16">
|
||||
Hole
|
||||
</th>
|
||||
<th className="py-2.5 px-3 font-semibold text-stone-300">Par</th>
|
||||
<th className="py-2.5 px-3 font-semibold text-stone-300">Yardage</th>
|
||||
<th className="py-2.5 px-3 font-semibold text-stone-300">Handicap (1-18)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-800/60 bg-stone-900 text-stone-300">
|
||||
{holes.slice(0, holesCount).map((hole, index) => (
|
||||
<tr key={hole.holeNumber} className="hover:bg-stone-850/60 transition-colors">
|
||||
<td className="py-2 px-3 text-left font-sans font-bold text-emerald-400">
|
||||
#{hole.holeNumber}
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<select
|
||||
value={hole.par}
|
||||
onChange={(e) =>
|
||||
handleHoleChange(index, 'par', Number(e.target.value))
|
||||
}
|
||||
className="bg-stone-800 border border-stone-700 text-white font-bold rounded-lg px-2.5 py-1 text-center focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value={3}>Par 3</option>
|
||||
<option value={4}>Par 4</option>
|
||||
<option value={5}>Par 5</option>
|
||||
<option value={6}>Par 6</option>
|
||||
</select>
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<input
|
||||
type="number"
|
||||
value={hole.yardage}
|
||||
min={50}
|
||||
max={750}
|
||||
onChange={(e) =>
|
||||
handleHoleChange(index, 'yardage', Number(e.target.value))
|
||||
}
|
||||
className="w-20 bg-stone-800 border border-stone-700 text-white rounded-lg px-2 py-1 text-center focus:outline-none focus:border-emerald-500 font-mono"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-1.5 px-2">
|
||||
<input
|
||||
type="number"
|
||||
value={hole.handicapIndex}
|
||||
min={1}
|
||||
max={18}
|
||||
onChange={(e) =>
|
||||
handleHoleChange(index, 'handicapIndex', Number(e.target.value))
|
||||
}
|
||||
className="w-16 bg-stone-800 border border-stone-700 text-stone-300 rounded-lg px-2 py-1 text-center focus:outline-none focus:border-emerald-500 font-mono"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl text-xs font-semibold text-stone-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!courseName.trim()}
|
||||
className="px-6 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white text-xs font-bold shadow-lg shadow-emerald-950/40 flex items-center gap-2 transition-all"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
<span>Save Course to Library</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,511 @@
|
||||
// Source: Google Maps Platform Code Assist
|
||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
APIProvider,
|
||||
Map,
|
||||
AdvancedMarker,
|
||||
useMap,
|
||||
} from '@vis.gl/react-google-maps';
|
||||
import {
|
||||
Navigation,
|
||||
Compass,
|
||||
Crosshair,
|
||||
Flag,
|
||||
MapPin,
|
||||
Footprints,
|
||||
RotateCcw,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
AlertCircle,
|
||||
Sparkles,
|
||||
Layers,
|
||||
} from 'lucide-react';
|
||||
import { Course, Hole, LatLngLiteral } from '../types/golf';
|
||||
import {
|
||||
getLiveGreenDistances,
|
||||
detectCurrentHoleFromGps,
|
||||
calculateDistanceMeters,
|
||||
calculateDestinationPoint,
|
||||
} from '../utils/geoUtils';
|
||||
|
||||
interface GolfGpsRangefinderProps {
|
||||
currentHole: Hole;
|
||||
course: Course;
|
||||
onSelectHole: (holeNumber: number) => void;
|
||||
unit: 'meters' | 'yards';
|
||||
onSaveDriveDistance?: (distance: number) => void;
|
||||
}
|
||||
|
||||
export const GolfGpsRangefinder: React.FC<GolfGpsRangefinderProps> = ({
|
||||
currentHole,
|
||||
course,
|
||||
onSelectHole,
|
||||
unit,
|
||||
onSaveDriveDistance,
|
||||
}) => {
|
||||
const apiKey = (import.meta as any).env.VITE_GOOGLE_MAPS_API_KEY || '';
|
||||
|
||||
// Player position state
|
||||
const [realUserPosition, setRealUserPosition] = useState<LatLngLiteral | null>(null);
|
||||
const [gpsAccuracy, setGpsAccuracy] = useState<number | null>(null);
|
||||
const [isGpsActive, setIsGpsActive] = useState(false);
|
||||
const [gpsError, setGpsError] = useState<string | null>(null);
|
||||
|
||||
// Simulation mode for testing when indoors or on desktop
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [simulatedPosition, setSimulatedPosition] = useState<LatLngLiteral | null>(null);
|
||||
|
||||
// Shot tracking
|
||||
const [shotOrigin, setShotOrigin] = useState<LatLngLiteral | null>(null);
|
||||
const [isMeasuringShot, setIsMeasuringShot] = useState(false);
|
||||
|
||||
// UI expand state
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [mapType, setMapType] = useState<'satellite' | 'hybrid' | 'roadmap'>('satellite');
|
||||
|
||||
// Active hole coordinates
|
||||
const holeCoords = currentHole.coordinates;
|
||||
|
||||
// Real or simulated user position
|
||||
const activePosition: LatLngLiteral | null = useMemo(() => {
|
||||
if (isSimulating && simulatedPosition) {
|
||||
return simulatedPosition;
|
||||
}
|
||||
if (realUserPosition) {
|
||||
return realUserPosition;
|
||||
}
|
||||
// Default fallback to tee box of current hole if no GPS
|
||||
return holeCoords ? holeCoords.tee : null;
|
||||
}, [isSimulating, simulatedPosition, realUserPosition, holeCoords]);
|
||||
|
||||
// Start real GPS watch
|
||||
useEffect(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setGpsError('Geolocation is not supported by your browser.');
|
||||
return;
|
||||
}
|
||||
|
||||
const watchId = navigator.geolocation.watchPosition(
|
||||
(pos) => {
|
||||
setRealUserPosition({
|
||||
lat: pos.coords.latitude,
|
||||
lng: pos.coords.longitude,
|
||||
});
|
||||
setGpsAccuracy(Math.round(pos.coords.accuracy));
|
||||
setIsGpsActive(true);
|
||||
setGpsError(null);
|
||||
},
|
||||
(err) => {
|
||||
console.warn('Geolocation error:', err.message);
|
||||
setGpsError('GPS signal searching. You can use Simulated GPS for demo.');
|
||||
setIsGpsActive(false);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 2000,
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
navigator.geolocation.clearWatch(watchId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// When current hole changes in simulation mode, reset simulation near the tee
|
||||
useEffect(() => {
|
||||
if (isSimulating && holeCoords) {
|
||||
setSimulatedPosition(holeCoords.tee);
|
||||
}
|
||||
}, [currentHole.holeNumber]);
|
||||
|
||||
// Live distances to Front, Center, Back of Green
|
||||
const greenDistances = useMemo(() => {
|
||||
if (!activePosition || !currentHole) return null;
|
||||
return getLiveGreenDistances(activePosition, currentHole, unit);
|
||||
}, [activePosition, currentHole, unit]);
|
||||
|
||||
// Automatic hole detection based on player's position
|
||||
const detectedHole = useMemo(() => {
|
||||
if (!activePosition) return null;
|
||||
return detectCurrentHoleFromGps(activePosition, course.holes);
|
||||
}, [activePosition, course.holes]);
|
||||
|
||||
// Shot distance calculation
|
||||
const measuredShotDistance = useMemo(() => {
|
||||
if (!shotOrigin || !activePosition) return 0;
|
||||
const m = calculateDistanceMeters(shotOrigin, activePosition);
|
||||
return unit === 'yards' ? Math.round(m * 1.09361) : m;
|
||||
}, [shotOrigin, activePosition, unit]);
|
||||
|
||||
// Simulation controls
|
||||
const handleToggleSimulation = () => {
|
||||
if (!isSimulating) {
|
||||
setIsSimulating(true);
|
||||
if (holeCoords) {
|
||||
setSimulatedPosition(holeCoords.tee);
|
||||
}
|
||||
} else {
|
||||
setIsSimulating(false);
|
||||
setSimulatedPosition(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSimulateAdvance = (meters: number) => {
|
||||
if (!simulatedPosition || !holeCoords) return;
|
||||
const bearing = currentHole.coordinates
|
||||
? Math.round(greenDistances?.bearingToCenter || 0)
|
||||
: 0;
|
||||
const nextPos = calculateDestinationPoint(simulatedPosition, meters, bearing);
|
||||
setSimulatedPosition(nextPos);
|
||||
};
|
||||
|
||||
const handleStartShotMeasurement = () => {
|
||||
if (activePosition) {
|
||||
setShotOrigin(activePosition);
|
||||
setIsMeasuringShot(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetShotMeasurement = () => {
|
||||
setShotOrigin(null);
|
||||
setIsMeasuringShot(false);
|
||||
};
|
||||
|
||||
const handleSaveMeasuredShot = () => {
|
||||
if (onSaveDriveDistance && measuredShotDistance > 0) {
|
||||
onSaveDriveDistance(measuredShotDistance);
|
||||
handleResetShotMeasurement();
|
||||
}
|
||||
};
|
||||
|
||||
// Center coordinate for Google Map
|
||||
const mapCenter = useMemo(() => {
|
||||
if (activePosition && holeCoords) {
|
||||
return {
|
||||
lat: (activePosition.lat + holeCoords.greenCenter.lat) / 2,
|
||||
lng: (activePosition.lng + holeCoords.greenCenter.lng) / 2,
|
||||
};
|
||||
}
|
||||
return holeCoords ? holeCoords.greenCenter : { lat: 36.5685, lng: -121.949 };
|
||||
}, [activePosition, holeCoords]);
|
||||
|
||||
return (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl overflow-hidden shadow-xl space-y-0 transition-all">
|
||||
{/* 1. Header with GPS Status and Simulation Switch */}
|
||||
<div className="bg-stone-850 px-4 py-3 border-b border-stone-800 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 rounded-xl bg-emerald-500/20 text-emerald-400 flex items-center justify-center">
|
||||
<Compass className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-bold text-white">
|
||||
<span>GPS Green Rangefinder</span>
|
||||
<span className="text-stone-500">·</span>
|
||||
<span className="text-emerald-400 font-mono">
|
||||
Hole #{currentHole.holeNumber}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[10px] text-stone-400">
|
||||
{isGpsActive ? (
|
||||
<span className="text-emerald-400 flex items-center gap-1">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
|
||||
Live GPS (±{gpsAccuracy}m)
|
||||
</span>
|
||||
) : isSimulating ? (
|
||||
<span className="text-amber-300 font-semibold">
|
||||
Demo Simulation GPS Active
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-400">GPS Standing by</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleSimulation}
|
||||
className={`px-2.5 py-1 rounded-xl text-[11px] font-bold transition-all ${
|
||||
isSimulating
|
||||
? 'bg-amber-500/20 border border-amber-400 text-amber-300'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{isSimulating ? 'Stop Demo' : 'Simulate GPS'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="p-1.5 rounded-lg text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
title={isExpanded ? 'Collapse' : 'Expand Rangefinder'}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<Minimize2 className="w-4 h-4" />
|
||||
) : (
|
||||
<Maximize2 className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Auto-Detected Hole Notification Banner */}
|
||||
{detectedHole &&
|
||||
detectedHole.holeNumber !== currentHole.holeNumber &&
|
||||
detectedHole.confidence !== 'low' && (
|
||||
<div className="bg-emerald-950/70 border-b border-emerald-800/60 px-4 py-2.5 flex items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-2 text-emerald-300">
|
||||
<Navigation className="w-4 h-4 text-emerald-400 animate-bounce" />
|
||||
<span>
|
||||
<strong>GPS auto-detected Hole {detectedHole.holeNumber}</strong> (
|
||||
{detectedHole.distanceToTee}m from tee box)
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onSelectHole(detectedHole.holeNumber)}
|
||||
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white font-bold rounded-lg transition-colors text-[11px] shrink-0"
|
||||
>
|
||||
Switch to Hole {detectedHole.holeNumber} →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 3. Bushnell/Garmin-Style Huge Digital Rangefinder HUD */}
|
||||
<div className="p-4 sm:p-5 bg-gradient-to-b from-stone-900 via-stone-900 to-stone-950">
|
||||
<div className="grid grid-cols-3 gap-2.5 sm:gap-4 text-center">
|
||||
{/* FRONT OF GREEN */}
|
||||
<div className="bg-stone-850 border border-sky-500/30 p-3 sm:p-4 rounded-2xl relative overflow-hidden">
|
||||
<span className="text-[10px] sm:text-xs font-bold uppercase tracking-wider text-sky-400 block">
|
||||
Front Edge
|
||||
</span>
|
||||
<div className="text-3xl sm:text-4xl font-extrabold font-mono text-sky-300 mt-0.5 tabular-nums">
|
||||
{greenDistances ? greenDistances.front : '—'}
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-mono">
|
||||
{unit === 'meters' ? 'meters' : 'yards'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* MIDDLE / PIN (Highlighted Center) */}
|
||||
<div className="bg-emerald-950/40 border-2 border-emerald-500 p-3 sm:p-4 rounded-2xl relative shadow-lg shadow-emerald-950/40">
|
||||
<div className="flex items-center justify-center gap-1 text-[10px] sm:text-xs font-extrabold uppercase tracking-wider text-emerald-400">
|
||||
<Flag className="w-3 h-3 text-emerald-400" />
|
||||
<span>Center / Pin</span>
|
||||
</div>
|
||||
<div className="text-4xl sm:text-5xl font-black font-mono text-white mt-0.5 tabular-nums tracking-tight">
|
||||
{greenDistances ? greenDistances.center : '—'}
|
||||
</div>
|
||||
<span className="text-[11px] text-emerald-400 font-bold font-mono">
|
||||
{unit === 'meters' ? 'meters' : 'yards'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* BACK OF GREEN */}
|
||||
<div className="bg-stone-850 border border-rose-500/30 p-3 sm:p-4 rounded-2xl relative overflow-hidden">
|
||||
<span className="text-[10px] sm:text-xs font-bold uppercase tracking-wider text-rose-400 block">
|
||||
Back Edge
|
||||
</span>
|
||||
<div className="text-3xl sm:text-4xl font-extrabold font-mono text-rose-300 mt-0.5 tabular-nums">
|
||||
{greenDistances ? greenDistances.back : '—'}
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-400 font-mono">
|
||||
{unit === 'meters' ? 'meters' : 'yards'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Shot Distance Tracking Bar */}
|
||||
<div className="mt-3.5 pt-3 border-t border-stone-800/80 flex flex-wrap items-center justify-between gap-3 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Footprints className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-stone-300 font-medium">Shot Distance:</span>
|
||||
{isMeasuringShot ? (
|
||||
<span className="text-sm font-extrabold font-mono text-emerald-400">
|
||||
{measuredShotDistance} {unit}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-stone-500">Not marked</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{!isMeasuringShot ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartShotMeasurement}
|
||||
className="px-3 py-1 bg-stone-800 hover:bg-stone-750 text-stone-200 text-xs font-bold rounded-xl transition-colors"
|
||||
>
|
||||
+ Mark Shot Origin
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSaveMeasuredShot}
|
||||
className="px-2.5 py-1 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl transition-colors"
|
||||
>
|
||||
Save as Drive ({measuredShotDistance} {unit})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetShotMeasurement}
|
||||
className="p-1 rounded-lg text-stone-400 hover:text-white"
|
||||
title="Reset shot measurement"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simulation Stepper (If Simulation mode is active) */}
|
||||
{isSimulating && (
|
||||
<div className="mt-3 p-3 bg-amber-950/30 border border-amber-800/50 rounded-2xl flex items-center justify-between gap-2 text-xs">
|
||||
<span className="text-amber-300 font-semibold">
|
||||
Simulate Player Walking:
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSimulateAdvance(30)}
|
||||
className="px-2.5 py-1 bg-stone-800 hover:bg-stone-700 text-white rounded-lg font-mono font-bold"
|
||||
>
|
||||
+30m Ahead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSimulateAdvance(60)}
|
||||
className="px-2.5 py-1 bg-stone-800 hover:bg-stone-700 text-white rounded-lg font-mono font-bold"
|
||||
>
|
||||
+60m Ahead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => holeCoords && setSimulatedPosition(holeCoords.tee)}
|
||||
className="px-2 py-1 text-stone-400 hover:text-white"
|
||||
title="Reset to Tee"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 4. Google Maps Satellite View with Markers & Distance Line */}
|
||||
<div
|
||||
className="w-full relative border-t border-stone-800 overflow-hidden"
|
||||
style={{ height: isExpanded ? '440px' : '260px' }}
|
||||
>
|
||||
{apiKey ? (
|
||||
<APIProvider apiKey={apiKey}>
|
||||
<Map
|
||||
defaultCenter={mapCenter}
|
||||
defaultZoom={17}
|
||||
mapId="DEMO_MAP_ID"
|
||||
mapTypeId={mapType}
|
||||
disableDefaultUI
|
||||
gestureHandling="greedy"
|
||||
internalUsageAttributionIds={['gmp_mcp_codeassist_v1_aistudio']}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
{/* Map Controls Overlay */}
|
||||
<div className="absolute top-3 right-3 z-10 flex gap-1 bg-stone-900/80 backdrop-blur-md p-1 rounded-xl border border-stone-700">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapType('satellite')}
|
||||
className={`px-2 py-1 rounded-lg text-[10px] font-bold ${
|
||||
mapType === 'satellite'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'text-stone-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Sat
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMapType('hybrid')}
|
||||
className={`px-2 py-1 rounded-lg text-[10px] font-bold ${
|
||||
mapType === 'hybrid'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'text-stone-300 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Hybrid
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Player Position Marker */}
|
||||
{activePosition && (
|
||||
<AdvancedMarker position={activePosition} title="Your Location">
|
||||
<div className="relative flex items-center justify-center">
|
||||
<div className="w-5 h-5 rounded-full bg-cyan-400 border-2 border-white shadow-lg shadow-cyan-500/50 flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full bg-cyan-900" />
|
||||
</div>
|
||||
<div className="absolute w-8 h-8 rounded-full border border-cyan-400 animate-ping opacity-75 pointer-events-none" />
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
|
||||
{/* Tee Marker */}
|
||||
{holeCoords && (
|
||||
<AdvancedMarker position={holeCoords.tee} title="Tee Box">
|
||||
<div className="w-6 h-6 rounded-lg bg-stone-900 border-2 border-white text-white font-extrabold text-[10px] flex items-center justify-center shadow-md">
|
||||
T
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
|
||||
{/* Front of Green Marker */}
|
||||
{holeCoords && (
|
||||
<AdvancedMarker
|
||||
position={holeCoords.greenFront}
|
||||
title="Front of Green"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-sky-500 border border-white shadow-md" />
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
|
||||
{/* Center of Green (Pin Flag) */}
|
||||
{holeCoords && (
|
||||
<AdvancedMarker
|
||||
position={holeCoords.greenCenter}
|
||||
title="Pin / Green Center"
|
||||
>
|
||||
<div className="relative flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-600 border-2 border-white text-white flex items-center justify-center shadow-lg">
|
||||
<Flag className="w-3.5 h-3.5 fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
|
||||
{/* Back of Green Marker */}
|
||||
{holeCoords && (
|
||||
<AdvancedMarker
|
||||
position={holeCoords.greenBack}
|
||||
title="Back of Green"
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-rose-500 border border-white shadow-md" />
|
||||
</AdvancedMarker>
|
||||
)}
|
||||
</Map>
|
||||
</APIProvider>
|
||||
) : (
|
||||
<div className="w-full h-full flex flex-col items-center justify-center bg-stone-950 p-6 text-center">
|
||||
<AlertCircle className="w-8 h-8 text-amber-400 mb-2" />
|
||||
<p className="text-xs text-stone-300 font-semibold">
|
||||
Google Maps API Key not detected
|
||||
</p>
|
||||
<p className="text-[11px] text-stone-500 mt-1 max-w-sm">
|
||||
Please configure your Google Maps API key to activate live satellite green rangefinding.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,930 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Crosshair,
|
||||
AlertTriangle,
|
||||
Minus,
|
||||
Plus,
|
||||
Compass,
|
||||
FileText,
|
||||
Sparkles,
|
||||
Waves,
|
||||
ShieldAlert,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Course,
|
||||
Hole,
|
||||
HoleScore,
|
||||
PenaltyType,
|
||||
TrackingSettings,
|
||||
FIRStatus,
|
||||
GIRStatus,
|
||||
} from '../types/golf';
|
||||
import { getScoreName, formatToPar } from '../utils/golfStats';
|
||||
import { GolfGpsRangefinder } from './GolfGpsRangefinder';
|
||||
|
||||
interface HoleEntryProps {
|
||||
currentHoleNumber: number;
|
||||
setCurrentHoleNumber: (hole: number) => void;
|
||||
course: Course;
|
||||
scores: Record<number, HoleScore>;
|
||||
onUpdateHoleScore: (holeNumber: number, updated: HoleScore) => void;
|
||||
settings: TrackingSettings;
|
||||
onViewScorecard: () => void;
|
||||
}
|
||||
|
||||
export const HoleEntry: React.FC<HoleEntryProps> = ({
|
||||
currentHoleNumber,
|
||||
setCurrentHoleNumber,
|
||||
course,
|
||||
scores,
|
||||
onUpdateHoleScore,
|
||||
settings,
|
||||
onViewScorecard,
|
||||
}) => {
|
||||
const currentHole: Hole = course.holes.find((h) => h.holeNumber === currentHoleNumber) || course.holes[0];
|
||||
const holeScore: HoleScore = scores[currentHoleNumber] || {
|
||||
holeNumber: currentHoleNumber,
|
||||
grossScore: currentHole.par,
|
||||
fir: currentHole.par === 3 ? 'na' : 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: null,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
notes: '',
|
||||
};
|
||||
|
||||
const [showGps, setShowGps] = useState(true);
|
||||
|
||||
const updateField = <K extends keyof HoleScore>(field: K, value: HoleScore[K]) => {
|
||||
const updated = { ...holeScore, [field]: value };
|
||||
onUpdateHoleScore(currentHoleNumber, updated);
|
||||
};
|
||||
|
||||
const currentGross = holeScore.grossScore || currentHole.par;
|
||||
const scoreDiff = currentGross - currentHole.par;
|
||||
const scoreTitle = getScoreName(currentGross, currentHole.par);
|
||||
|
||||
const handleScoreAdjust = (delta: number) => {
|
||||
const nextScore = Math.max(1, currentGross + delta);
|
||||
updateField('grossScore', nextScore);
|
||||
};
|
||||
|
||||
const handleQuickScore = (targetScore: number) => {
|
||||
updateField('grossScore', targetScore);
|
||||
};
|
||||
|
||||
// Penalty handlers
|
||||
const currentPenalties = holeScore.penalties || [];
|
||||
const totalPenalties = currentPenalties.reduce((sum, p) => sum + p.strokes, 0);
|
||||
|
||||
const togglePenaltyType = (type: PenaltyType) => {
|
||||
const existingIndex = currentPenalties.findIndex((p) => p.type === type);
|
||||
let newPenalties = [...currentPenalties];
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
newPenalties.splice(existingIndex, 1);
|
||||
} else {
|
||||
newPenalties.push({
|
||||
id: `pen-${Date.now()}-${type}`,
|
||||
type,
|
||||
strokes: 1,
|
||||
});
|
||||
}
|
||||
updateField('penalties', newPenalties);
|
||||
};
|
||||
|
||||
const setTotalPenaltyStrokes = (strokes: number) => {
|
||||
if (strokes === 0) {
|
||||
updateField('penalties', []);
|
||||
} else {
|
||||
// If none exist, default to water hazard
|
||||
if (currentPenalties.length === 0) {
|
||||
updateField('penalties', [
|
||||
{ id: `pen-${Date.now()}`, type: 'water', strokes },
|
||||
]);
|
||||
} else {
|
||||
// adjust the first one's stroke count
|
||||
const updated = [...currentPenalties];
|
||||
updated[0] = { ...updated[0], strokes };
|
||||
updateField('penalties', updated);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Bunker handlers
|
||||
const bunkerState = holeScore.bunker || {
|
||||
hit: false,
|
||||
fairwayBunker: false,
|
||||
greensideBunker: false,
|
||||
sandSave: null,
|
||||
};
|
||||
|
||||
const toggleBunkerHit = (hit: boolean) => {
|
||||
if (!hit) {
|
||||
updateField('bunker', {
|
||||
hit: false,
|
||||
fairwayBunker: false,
|
||||
greensideBunker: false,
|
||||
sandSave: null,
|
||||
});
|
||||
} else {
|
||||
// Default to greenside bunker
|
||||
updateField('bunker', {
|
||||
...bunkerState,
|
||||
hit: true,
|
||||
greensideBunker: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFairwayBunker = () => {
|
||||
const willFairway = !bunkerState.fairwayBunker;
|
||||
const willHit = willFairway || bunkerState.greensideBunker;
|
||||
updateField('bunker', {
|
||||
...bunkerState,
|
||||
hit: willHit,
|
||||
fairwayBunker: willFairway,
|
||||
});
|
||||
};
|
||||
|
||||
const toggleGreensideBunker = () => {
|
||||
const willGreenside = !bunkerState.greensideBunker;
|
||||
const willHit = willGreenside || bunkerState.fairwayBunker;
|
||||
updateField('bunker', {
|
||||
...bunkerState,
|
||||
hit: willHit,
|
||||
greensideBunker: willGreenside,
|
||||
sandSave: willGreenside ? bunkerState.sandSave : null,
|
||||
});
|
||||
};
|
||||
|
||||
const clubsList = [
|
||||
'Driver',
|
||||
'3-Wood',
|
||||
'5-Wood',
|
||||
'Hybrid',
|
||||
'3-Iron',
|
||||
'4-Iron',
|
||||
'5-Iron',
|
||||
'6-Iron',
|
||||
'7-Iron',
|
||||
'8-Iron',
|
||||
'9-Iron',
|
||||
'Pitching Wedge',
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16">
|
||||
{/* 1. Quick Hole Navigation Strip */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-2.5 sm:p-3 shadow-md">
|
||||
<div className="flex items-center justify-between gap-1 overflow-x-auto pb-1 sm:pb-0">
|
||||
{course.holes.map((h) => {
|
||||
const hScore = scores[h.holeNumber];
|
||||
const hasScore = hScore && hScore.grossScore !== null && hScore.grossScore > 0;
|
||||
const diff = hasScore ? hScore.grossScore - h.par : null;
|
||||
const isSelected = h.holeNumber === currentHoleNumber;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={h.holeNumber}
|
||||
onClick={() => setCurrentHoleNumber(h.holeNumber)}
|
||||
className={`flex-shrink-0 flex flex-col items-center justify-center min-w-[38px] sm:min-w-[42px] h-12 rounded-xl text-xs transition-all ${
|
||||
isSelected
|
||||
? 'bg-emerald-600 text-white font-bold ring-2 ring-emerald-400 shadow-md scale-105'
|
||||
: hasScore
|
||||
? 'bg-stone-800/90 text-stone-200 hover:bg-stone-700/80'
|
||||
: 'bg-stone-900/60 text-stone-500 hover:bg-stone-800/60'
|
||||
}`}
|
||||
>
|
||||
<span className="font-semibold">{h.holeNumber}</span>
|
||||
<span className="text-[10px] font-mono tabular-nums leading-none mt-0.5">
|
||||
{hasScore ? (diff === 0 ? 'E' : formatToPar(diff!)) : `P${h.par}`}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Current Hole Header Card */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-lg relative overflow-hidden">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setCurrentHoleNumber(Math.max(1, currentHoleNumber - 1))}
|
||||
disabled={currentHoleNumber === 1}
|
||||
className="w-11 h-11 rounded-xl bg-stone-800 hover:bg-stone-700 disabled:opacity-30 disabled:pointer-events-none flex items-center justify-center text-stone-200 transition-colors"
|
||||
aria-label="Previous Hole"
|
||||
>
|
||||
<ChevronLeft className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="text-center">
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<span className="text-xs uppercase tracking-wider font-semibold text-emerald-400">
|
||||
Hole {currentHole.holeNumber} of {course.holes.length}
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-xs text-stone-400">Handicap {currentHole.handicapIndex}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-center gap-3 mt-1">
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-white">
|
||||
Par {currentHole.par}
|
||||
</h2>
|
||||
<span className="text-stone-400 font-mono text-sm">
|
||||
{currentHole.yardage} {settings.distanceUnit}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setCurrentHoleNumber(Math.min(course.holes.length, currentHoleNumber + 1))}
|
||||
disabled={currentHoleNumber === course.holes.length}
|
||||
className="w-11 h-11 rounded-xl bg-stone-800 hover:bg-stone-700 disabled:opacity-30 disabled:pointer-events-none flex items-center justify-center text-stone-200 transition-colors"
|
||||
aria-label="Next Hole"
|
||||
>
|
||||
<ChevronRight className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* GPS Rangefinder Toggle Button */}
|
||||
<div className="mt-3 pt-3 border-t border-stone-800/80 flex items-center justify-between text-xs">
|
||||
<div className="flex items-center gap-1.5 text-stone-400">
|
||||
<Compass className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Google Maps Satellite & Green Rangefinder</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowGps(!showGps)}
|
||||
className="px-2.5 py-1 rounded-lg bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white font-semibold transition-colors"
|
||||
>
|
||||
{showGps ? 'Hide Rangefinder' : 'Show Live Rangefinder'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2.5 Active Google Maps GPS Rangefinder (Front / Middle / Back) */}
|
||||
{showGps && (
|
||||
<GolfGpsRangefinder
|
||||
currentHole={currentHole}
|
||||
course={course}
|
||||
onSelectHole={setCurrentHoleNumber}
|
||||
unit={settings.distanceUnit}
|
||||
onSaveDriveDistance={(dist) => updateField('driveDistanceYds', dist)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 3. Main Gross Score Stepper & Dial */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-6 shadow-md text-center">
|
||||
<div className="text-xs font-semibold uppercase tracking-wider text-stone-400 mb-2">
|
||||
Total Hole Strokes
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-center gap-6 my-2">
|
||||
<button
|
||||
onClick={() => handleScoreAdjust(-1)}
|
||||
disabled={currentGross <= 1}
|
||||
className="w-14 h-14 rounded-2xl bg-stone-800 hover:bg-stone-700 text-stone-200 active:scale-95 disabled:opacity-30 flex items-center justify-center transition-all text-xl font-bold shadow-sm"
|
||||
aria-label="Decrease score"
|
||||
>
|
||||
<Minus className="w-6 h-6" />
|
||||
</button>
|
||||
|
||||
<div className="flex flex-col items-center min-w-[120px]">
|
||||
<span className="text-5xl sm:text-6xl font-extrabold text-white font-mono tabular-nums tracking-tight">
|
||||
{currentGross}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-1 inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold uppercase tracking-wider ${
|
||||
scoreDiff < -1
|
||||
? 'bg-amber-500/20 text-amber-300 ring-1 ring-amber-400/40'
|
||||
: scoreDiff === -1
|
||||
? 'bg-emerald-500/20 text-emerald-300 ring-1 ring-emerald-400/40'
|
||||
: scoreDiff === 0
|
||||
? 'bg-stone-800 text-stone-300 ring-1 ring-stone-700'
|
||||
: scoreDiff === 1
|
||||
? 'bg-amber-600/20 text-amber-400 ring-1 ring-amber-500/40'
|
||||
: 'bg-rose-500/20 text-rose-300 ring-1 ring-rose-500/40'
|
||||
}`}
|
||||
>
|
||||
{scoreTitle}
|
||||
<span className="text-[11px] font-mono opacity-80">({formatToPar(scoreDiff)})</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => handleScoreAdjust(1)}
|
||||
className="w-14 h-14 rounded-2xl bg-stone-800 hover:bg-stone-700 text-stone-200 active:scale-95 flex items-center justify-center transition-all text-xl font-bold shadow-sm"
|
||||
aria-label="Increase score"
|
||||
>
|
||||
<Plus className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Touch Bar for Standard Scores */}
|
||||
<div className="grid grid-cols-5 gap-1.5 sm:gap-2 max-w-md mx-auto mt-5">
|
||||
{[-2, -1, 0, 1, 2].map((offset) => {
|
||||
const targetScore = currentHole.par + offset;
|
||||
if (targetScore < 1) return null;
|
||||
const isCurrent = currentGross === targetScore;
|
||||
return (
|
||||
<button
|
||||
key={offset}
|
||||
onClick={() => handleQuickScore(targetScore)}
|
||||
className={`py-2 px-1 rounded-xl text-xs font-semibold transition-all ${
|
||||
isCurrent
|
||||
? 'bg-emerald-600 text-white shadow-md font-bold'
|
||||
: 'bg-stone-800/80 text-stone-300 hover:bg-stone-700 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<div className="font-mono text-sm leading-tight">{targetScore}</div>
|
||||
<div className="text-[10px] opacity-80 leading-none mt-0.5">
|
||||
{offset === 0 ? 'Par' : offset < 0 ? `${offset}` : `+${offset}`}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Fairway in Regulation (FIR) Section */}
|
||||
{settings.trackFIR && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Compass className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-semibold text-white">Fairway Hit (FIR)</span>
|
||||
</div>
|
||||
{currentHole.par === 3 ? (
|
||||
<span className="text-xs text-stone-500 font-medium">Par 3 (Not counted for FIR)</span>
|
||||
) : (
|
||||
<span className="text-xs text-stone-400">Par {currentHole.par} Hole</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{currentHole.par === 3 ? (
|
||||
<div className="bg-stone-800/40 rounded-xl p-3 text-center text-xs text-stone-400">
|
||||
Fairways in regulation apply to Par 4 and Par 5 holes only.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 sm:grid-cols-4 gap-2">
|
||||
<button
|
||||
onClick={() => updateField('fir', 'left')}
|
||||
className={`py-3 px-2 rounded-xl text-xs font-semibold flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
holeScore.fir === 'left'
|
||||
? 'bg-amber-600 text-white font-bold shadow-md ring-2 ring-amber-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">⬅</span>
|
||||
<span>Miss Left</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateField('fir', 'hit')}
|
||||
className={`py-3 px-2 rounded-xl text-xs font-semibold flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
holeScore.fir === 'hit'
|
||||
? 'bg-emerald-600 text-white font-bold shadow-md ring-2 ring-emerald-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<Crosshair className="w-4 h-4" />
|
||||
<span>Fairway Hit</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => updateField('fir', 'right')}
|
||||
className={`py-3 px-2 rounded-xl text-xs font-semibold flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
holeScore.fir === 'right'
|
||||
? 'bg-amber-600 text-white font-bold shadow-md ring-2 ring-amber-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">➡</span>
|
||||
<span>Miss Right</span>
|
||||
</button>
|
||||
|
||||
{settings.trackFIRDirection && (
|
||||
<button
|
||||
onClick={() => updateField('fir', 'short')}
|
||||
className={`py-3 px-2 rounded-xl text-xs font-semibold flex flex-col items-center justify-center gap-1 col-span-3 sm:col-span-1 transition-all ${
|
||||
holeScore.fir === 'short'
|
||||
? 'bg-amber-600 text-white font-bold shadow-md ring-2 ring-amber-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">⬇</span>
|
||||
<span>Miss Short</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 5. Green in Regulation (GIR) Section */}
|
||||
{settings.trackGIR && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crosshair className="w-4 h-4 text-emerald-400" />
|
||||
<span className="text-sm font-semibold text-white">Green in Regulation (GIR)</span>
|
||||
</div>
|
||||
<span className="text-xs text-stone-400">
|
||||
Target: Reach in {currentHole.par - 2} strokes
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => updateField('gir', 'hit')}
|
||||
className={`py-3 px-4 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all ${
|
||||
holeScore.gir === 'hit'
|
||||
? 'bg-emerald-600 text-white font-bold shadow-md ring-2 ring-emerald-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<Crosshair className="w-4 h-4" />
|
||||
<span>Green Hit (On in Reg)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() =>
|
||||
updateField('gir', holeScore.gir === 'hit' ? 'miss_short' : 'miss')
|
||||
}
|
||||
className={`py-3 px-4 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all ${
|
||||
holeScore.gir !== 'hit'
|
||||
? 'bg-stone-800 border-2 border-stone-600 text-white font-bold'
|
||||
: 'bg-stone-800/80 text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<span>Missed Green</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Miss Direction (If enabled and missed green) */}
|
||||
{settings.trackGIRDirection && holeScore.gir !== 'hit' && (
|
||||
<div className="bg-stone-800/60 p-3 rounded-xl">
|
||||
<div className="text-[11px] font-semibold text-stone-400 uppercase tracking-wider mb-2">
|
||||
Missed Green Location:
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-1.5">
|
||||
{[
|
||||
{ id: 'miss_short', label: 'Short ⬇' },
|
||||
{ id: 'miss_long', label: 'Long ⬆' },
|
||||
{ id: 'miss_left', label: 'Left ⬅' },
|
||||
{ id: 'miss_right', label: 'Right ➡' },
|
||||
].map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => updateField('gir', item.id as GIRStatus)}
|
||||
className={`py-2 px-1 rounded-lg text-xs font-semibold transition-all ${
|
||||
holeScore.gir === item.id
|
||||
? 'bg-amber-600 text-white shadow-sm'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 6. Putting on Green & Foregreen / Fringe Putts */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Putts on Green Surface */}
|
||||
{settings.trackPutts && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
|
||||
<span className="text-sm font-semibold text-white">Putts (On Green)</span>
|
||||
</div>
|
||||
<span className="text-xs font-mono text-emerald-400 font-bold">
|
||||
{holeScore.putts} {holeScore.putts === 1 ? 'putt' : 'putts'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-5 gap-1.5">
|
||||
{[0, 1, 2, 3, 4].map((pCount) => (
|
||||
<button
|
||||
key={pCount}
|
||||
onClick={() => updateField('putts', pCount)}
|
||||
className={`py-3 rounded-xl text-sm font-mono font-bold transition-all ${
|
||||
holeScore.putts === pCount
|
||||
? 'bg-emerald-600 text-white shadow-md ring-2 ring-emerald-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{pCount === 4 ? '4+' : pCount}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{settings.trackFirstPuttDistance && (
|
||||
<div className="mt-3 pt-3 border-t border-stone-800 flex items-center justify-between">
|
||||
<span className="text-xs text-stone-400">First Putt Distance</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="150"
|
||||
placeholder="Ft"
|
||||
value={holeScore.firstPuttDistanceFt ?? ''}
|
||||
onChange={(e) =>
|
||||
updateField(
|
||||
'firstPuttDistanceFt',
|
||||
e.target.value ? Number(e.target.value) : null
|
||||
)
|
||||
}
|
||||
className="w-16 bg-stone-800 border border-stone-700 text-white text-xs font-mono rounded-lg px-2 py-1 text-center focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
<span className="text-xs text-stone-400">{settings.puttDistanceUnit}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Foregreen / Off-Green (Fringe) Putts */}
|
||||
{settings.trackForegreen && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm relative">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-2.5 h-2.5 rounded-full bg-teal-400"></span>
|
||||
<span className="text-sm font-semibold text-white">Foregreen / Fringe Putts</span>
|
||||
</div>
|
||||
<span className="text-xs text-teal-400 font-mono font-bold">
|
||||
{holeScore.foregreenPutts || 0} off-green
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-stone-400 mb-3 leading-relaxed">
|
||||
Putts taken from collar or fringe with a putter (tracked separately from official green putts).
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 bg-stone-800/80 p-2.5 rounded-xl">
|
||||
<span className="text-xs text-stone-300 font-medium">Fringe Strokes:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
updateField('foregreenPutts', Math.max(0, (holeScore.foregreenPutts || 0) - 1))
|
||||
}
|
||||
disabled={(holeScore.foregreenPutts || 0) <= 0}
|
||||
className="w-8 h-8 rounded-lg bg-stone-700 hover:bg-stone-600 disabled:opacity-30 text-white flex items-center justify-center font-bold"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<span className="w-8 text-center font-mono font-bold text-white text-base">
|
||||
{holeScore.foregreenPutts || 0}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateField('foregreenPutts', (holeScore.foregreenPutts || 0) + 1)
|
||||
}
|
||||
className="w-8 h-8 rounded-lg bg-stone-700 hover:bg-stone-600 text-white flex items-center justify-center font-bold"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer text-xs text-stone-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={holeScore.firstPuttFromFringe || false}
|
||||
onChange={(e) => updateField('firstPuttFromFringe', e.target.checked)}
|
||||
className="w-4 h-4 rounded text-teal-500 bg-stone-800 border-stone-700 focus:ring-0"
|
||||
/>
|
||||
<span>First putt was taken from foregreen / fringe</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 7. Bunker Hits Section (Fairway Bunker vs Greenside Bunker) */}
|
||||
{settings.trackBunkers && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-base">🏖️</span>
|
||||
<span className="text-sm font-semibold text-white">Bunker / Sand Encounters</span>
|
||||
</div>
|
||||
{bunkerState.hit && (
|
||||
<span className="text-xs font-semibold px-2 py-0.5 rounded bg-amber-500/20 text-amber-300">
|
||||
Sand Hit
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 mb-3">
|
||||
<button
|
||||
onClick={() => toggleBunkerHit(false)}
|
||||
className={`py-3 rounded-xl text-xs font-semibold transition-all ${
|
||||
!bunkerState.hit
|
||||
? 'bg-stone-800 border border-stone-700 text-white font-bold'
|
||||
: 'bg-stone-900 text-stone-400 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
No Bunkers Hit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => toggleBunkerHit(true)}
|
||||
className={`py-3 rounded-xl text-xs font-semibold transition-all ${
|
||||
bunkerState.hit
|
||||
? 'bg-amber-600 text-white font-bold shadow-md ring-2 ring-amber-400'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
Hit Bunker (Sand)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Bunker Sub-Types: Fairway vs Greenside */}
|
||||
{bunkerState.hit && settings.trackBunkerTypes && (
|
||||
<div className="bg-stone-800/70 p-3.5 rounded-xl space-y-3 mt-3">
|
||||
<div className="text-xs font-semibold text-stone-300">
|
||||
Which bunker(s) did you hit on this hole?
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFairwayBunker}
|
||||
className={`py-2.5 px-3 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all ${
|
||||
bunkerState.fairwayBunker
|
||||
? 'bg-amber-500/20 border-2 border-amber-400 text-amber-300 font-bold'
|
||||
: 'bg-stone-800 border border-stone-700 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<span>{bunkerState.fairwayBunker ? '✓' : '+'}</span>
|
||||
<span>Fairway Bunker</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleGreensideBunker}
|
||||
className={`py-2.5 px-3 rounded-xl text-xs font-semibold flex items-center justify-center gap-2 transition-all ${
|
||||
bunkerState.greensideBunker
|
||||
? 'bg-amber-500/20 border-2 border-amber-400 text-amber-300 font-bold'
|
||||
: 'bg-stone-800 border border-stone-700 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<span>{bunkerState.greensideBunker ? '✓' : '+'}</span>
|
||||
<span>Greenside Bunker</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sand Save Toggle (for greenside bunkers) */}
|
||||
{bunkerState.greensideBunker && settings.trackSandSaves && (
|
||||
<div className="pt-2 border-t border-stone-700 flex items-center justify-between">
|
||||
<div className="text-xs text-stone-300">
|
||||
<span className="font-semibold text-white">Sand Save:</span> Made par or better from greenside sand?
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
onClick={() =>
|
||||
updateField('bunker', { ...bunkerState, sandSave: true })
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
||||
bunkerState.sandSave === true
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
Yes (Saved)
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
updateField('bunker', { ...bunkerState, sandSave: false })
|
||||
}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-colors ${
|
||||
bunkerState.sandSave === false
|
||||
? 'bg-stone-700 text-stone-300'
|
||||
: 'bg-stone-800 text-stone-400 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
No
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 8. Penalty Strokes & Detailed Types Section */}
|
||||
{settings.trackPenalties && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||
<span className="text-sm font-semibold text-white">Penalty Strokes</span>
|
||||
</div>
|
||||
{totalPenalties > 0 && (
|
||||
<span className="text-xs font-bold px-2 py-0.5 rounded bg-rose-500/20 text-rose-300 font-mono">
|
||||
+{totalPenalties} penalty stroke{totalPenalties > 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Penalty Stroke Quick Stepper */}
|
||||
<div className="flex items-center justify-between gap-3 bg-stone-800/60 p-3 rounded-xl mb-3">
|
||||
<span className="text-xs text-stone-300 font-medium">Strokes Incurred:</span>
|
||||
<div className="flex gap-1.5">
|
||||
{[0, 1, 2, 3].map((strokes) => (
|
||||
<button
|
||||
key={strokes}
|
||||
onClick={() => setTotalPenaltyStrokes(strokes)}
|
||||
className={`w-10 h-9 rounded-lg text-xs font-mono font-bold transition-all ${
|
||||
totalPenalties === strokes
|
||||
? 'bg-rose-600 text-white shadow-md'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
{strokes === 0 ? 'None' : `+${strokes}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Specific Penalty Types (Water, OB, Lost Ball, Unplayable) */}
|
||||
{totalPenalties > 0 && settings.trackPenaltyTypes && (
|
||||
<div className="bg-stone-800/80 p-3.5 rounded-xl space-y-2.5">
|
||||
<div className="text-xs font-semibold text-stone-300">
|
||||
Penalty Category / Cause:
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2">
|
||||
{[
|
||||
{
|
||||
type: 'water' as PenaltyType,
|
||||
label: 'Water / Hazard',
|
||||
icon: Waves,
|
||||
color: 'text-sky-400',
|
||||
},
|
||||
{
|
||||
type: 'ob' as PenaltyType,
|
||||
label: 'Out of Bounds (OB)',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-rose-400',
|
||||
},
|
||||
{
|
||||
type: 'lost_ball' as PenaltyType,
|
||||
label: 'Lost Ball',
|
||||
icon: HelpCircle,
|
||||
color: 'text-amber-400',
|
||||
},
|
||||
{
|
||||
type: 'unplayable' as PenaltyType,
|
||||
label: 'Unplayable / Other',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-purple-400',
|
||||
},
|
||||
].map((pen) => {
|
||||
const isSelected = currentPenalties.some((p) => p.type === pen.type);
|
||||
const Icon = pen.icon;
|
||||
return (
|
||||
<button
|
||||
key={pen.type}
|
||||
type="button"
|
||||
onClick={() => togglePenaltyType(pen.type)}
|
||||
className={`py-2.5 px-2 rounded-xl text-xs font-semibold flex flex-col items-center justify-center gap-1 transition-all ${
|
||||
isSelected
|
||||
? 'bg-rose-500/20 border-2 border-rose-400 text-white font-bold'
|
||||
: 'bg-stone-800 border border-stone-700 text-stone-300 hover:bg-stone-750'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`w-4 h-4 ${pen.color}`} />
|
||||
<span className="text-center leading-tight">{pen.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 9. Up & Down (Scramble) - Shown if GIR was missed */}
|
||||
{settings.trackUpAndDown && holeScore.gir !== 'hit' && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-4 shadow-sm flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-emerald-400" />
|
||||
<div>
|
||||
<span className="text-xs font-semibold text-white block">Up & Down (Scramble)</span>
|
||||
<span className="text-[11px] text-stone-400">Did you get up and down to save par?</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => updateField('upAndDown', true)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
holeScore.upAndDown === true
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-300 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
Saved Par
|
||||
</button>
|
||||
<button
|
||||
onClick={() => updateField('upAndDown', false)}
|
||||
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${
|
||||
holeScore.upAndDown === false
|
||||
? 'bg-stone-700 text-stone-300'
|
||||
: 'bg-stone-800 text-stone-400 hover:bg-stone-700'
|
||||
}`}
|
||||
>
|
||||
Missed
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 10. Optional Club Used & Hole Notes */}
|
||||
{(settings.trackDriveClub || settings.trackHoleNotes) && (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm space-y-4">
|
||||
{settings.trackDriveClub && (
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-2">
|
||||
Tee Shot Club:
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{clubsList.map((club) => (
|
||||
<button
|
||||
key={club}
|
||||
type="button"
|
||||
onClick={() => updateField('driveClub', club)}
|
||||
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-colors ${
|
||||
holeScore.driveClub === club
|
||||
? 'bg-emerald-600 text-white font-bold'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{club}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{settings.trackHoleNotes && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-xs font-semibold text-stone-300 mb-2">
|
||||
<FileText className="w-3.5 h-3.5 text-stone-400" />
|
||||
<span>Hole Notes & Lie:</span>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="e.g. Great 7-iron out of rough, curled 12-foot putt..."
|
||||
value={holeScore.notes || ''}
|
||||
onChange={(e) => updateField('notes', e.target.value)}
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-xs text-white placeholder-stone-500 focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 11. Bottom Action Bar */}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<button
|
||||
onClick={onViewScorecard}
|
||||
className="px-4 py-3 bg-stone-800 hover:bg-stone-750 text-stone-300 hover:text-white text-xs font-semibold rounded-xl transition-colors"
|
||||
>
|
||||
View Full Card
|
||||
</button>
|
||||
|
||||
{currentHoleNumber < course.holes.length ? (
|
||||
<button
|
||||
onClick={() => setCurrentHoleNumber(currentHoleNumber + 1)}
|
||||
className="flex-1 max-w-xs py-3 px-6 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold rounded-xl shadow-lg shadow-emerald-950/40 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
>
|
||||
<span>Save & Next Hole</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onViewScorecard}
|
||||
className="flex-1 max-w-xs py-3 px-6 bg-emerald-600 hover:bg-emerald-500 text-white text-sm font-bold rounded-xl shadow-lg shadow-emerald-950/40 flex items-center justify-center gap-2 transition-all active:scale-98"
|
||||
>
|
||||
<span>Finish Round & View Card</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,327 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Flag,
|
||||
Table,
|
||||
BarChart3,
|
||||
Settings,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
Trophy,
|
||||
Users,
|
||||
User as UserIcon,
|
||||
LogOut,
|
||||
Shield,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { Round } from '../types/golf';
|
||||
import { formatToPar } from '../utils/golfStats';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
|
||||
export type ActiveTab = 'hole' | 'scorecard' | 'analytics' | 'settings';
|
||||
|
||||
interface NavbarProps {
|
||||
activeTab: ActiveTab;
|
||||
setActiveTab: (tab: ActiveTab) => void;
|
||||
currentRound: Round;
|
||||
onNewRound: () => void;
|
||||
onOpenRounds: () => void;
|
||||
onOpenAchievements: () => void;
|
||||
onOpenCommunity: () => void;
|
||||
onOpenAuth: () => void;
|
||||
achievementsUnlockedCount: number;
|
||||
scoreSummary: {
|
||||
totalGross: number;
|
||||
toPar: number;
|
||||
holesPlayedCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export const Navbar: React.FC<NavbarProps> = ({
|
||||
activeTab,
|
||||
setActiveTab,
|
||||
currentRound,
|
||||
onNewRound,
|
||||
onOpenRounds,
|
||||
onOpenAchievements,
|
||||
onOpenCommunity,
|
||||
onOpenAuth,
|
||||
achievementsUnlockedCount,
|
||||
scoreSummary,
|
||||
}) => {
|
||||
const { user, logout, updateProfile } = useAuth();
|
||||
const [profileDropdownOpen, setProfileDropdownOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top Header - Desktop & Tablet */}
|
||||
<header className="sticky top-0 z-30 bg-stone-900/95 backdrop-blur-md border-b border-stone-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 h-16 flex items-center justify-between gap-4 sm:gap-6">
|
||||
{/* Zone 1: Brand Wordmark */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button
|
||||
onClick={() => setActiveTab('hole')}
|
||||
className="text-left flex items-center gap-2.5 focus:outline-none group"
|
||||
>
|
||||
<div className="w-9 h-9 rounded-xl bg-gradient-to-br from-emerald-500 to-emerald-700 flex items-center justify-center text-white shadow-md shadow-emerald-950/40">
|
||||
<Flag className="w-4 h-4 text-emerald-100" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-lg font-extrabold tracking-tight text-white group-hover:text-emerald-400 transition-colors">
|
||||
PinPoint Golf
|
||||
</span>
|
||||
<span className="hidden lg:inline-block ml-2 text-xs font-medium text-stone-400">
|
||||
{currentRound.courseName}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Zone 2: Navigation Links */}
|
||||
<nav className="hidden md:flex items-center gap-1 sm:gap-1.5 text-sm font-medium">
|
||||
<button
|
||||
onClick={() => setActiveTab('hole')}
|
||||
className={`px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
|
||||
activeTab === 'hole'
|
||||
? 'bg-stone-800 text-emerald-400 font-semibold shadow-sm'
|
||||
: 'text-stone-300 hover:text-white hover:bg-stone-800/60'
|
||||
}`}
|
||||
>
|
||||
Hole Entry
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('scorecard')}
|
||||
className={`px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
|
||||
activeTab === 'scorecard'
|
||||
? 'bg-stone-800 text-emerald-400 font-semibold shadow-sm'
|
||||
: 'text-stone-300 hover:text-white hover:bg-stone-800/60'
|
||||
}`}
|
||||
>
|
||||
Scorecard
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
|
||||
activeTab === 'analytics'
|
||||
? 'bg-stone-800 text-emerald-400 font-semibold shadow-sm'
|
||||
: 'text-stone-300 hover:text-white hover:bg-stone-800/60'
|
||||
}`}
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`px-3 py-1.5 rounded-lg transition-colors whitespace-nowrap ${
|
||||
activeTab === 'settings'
|
||||
? 'bg-stone-800 text-emerald-400 font-semibold shadow-sm'
|
||||
: 'text-stone-300 hover:text-white hover:bg-stone-800/60'
|
||||
}`}
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
|
||||
{/* Community / Leaderboard Link */}
|
||||
<button
|
||||
onClick={onOpenCommunity}
|
||||
className="px-3 py-1.5 rounded-lg text-amber-300/90 hover:text-amber-200 hover:bg-amber-500/10 transition-colors flex items-center gap-1.5"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span>Community</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Zone 3: Score Quick Pill + Actions + Auth */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-2.5 shrink-0">
|
||||
{/* Live Score Tally */}
|
||||
<div className="flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-3 py-1 bg-stone-800/90 border border-stone-700/80 rounded-lg text-xs font-mono tabular-nums">
|
||||
<span className="text-stone-400 hidden xs:inline">Score</span>
|
||||
<span className="font-bold text-white text-xs sm:text-sm">
|
||||
{scoreSummary.totalGross > 0 ? scoreSummary.totalGross : '—'}
|
||||
</span>
|
||||
<span
|
||||
className={`font-semibold px-1.5 py-0.5 rounded text-[11px] sm:text-xs ${
|
||||
scoreSummary.toPar < 0
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: scoreSummary.toPar > 0
|
||||
? 'bg-amber-500/20 text-amber-400'
|
||||
: 'bg-stone-700 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{formatToPar(scoreSummary.toPar)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Achievements Trophy Button */}
|
||||
<button
|
||||
onClick={onOpenAchievements}
|
||||
className="flex items-center gap-1 px-2 sm:px-2.5 py-1.5 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 border border-amber-500/30 rounded-lg text-xs font-bold transition-all shadow-sm"
|
||||
title="Career Trophies & Achievements"
|
||||
>
|
||||
<Trophy className="w-3.5 h-3.5 text-amber-400" />
|
||||
<span className="font-mono text-xs">{achievementsUnlockedCount}</span>
|
||||
</button>
|
||||
|
||||
{/* Mobile Community Button */}
|
||||
<button
|
||||
onClick={onOpenCommunity}
|
||||
className="md:hidden p-1.5 text-amber-300 hover:bg-amber-500/10 rounded-lg transition"
|
||||
title="Community Leaderboard"
|
||||
>
|
||||
<Users className="w-4 h-4 text-amber-400" />
|
||||
</button>
|
||||
|
||||
{/* Saved Rounds */}
|
||||
<button
|
||||
onClick={onOpenRounds}
|
||||
title="Saved Rounds"
|
||||
className="p-1.5 sm:p-2 text-stone-300 hover:text-white hover:bg-stone-800 rounded-lg transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
{/* New Round */}
|
||||
<button
|
||||
onClick={onNewRound}
|
||||
className="hidden sm:flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3 py-1.5 text-xs font-semibold text-white bg-emerald-600 hover:bg-emerald-500 rounded-lg shadow-sm transition-colors whitespace-nowrap"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>New Round</span>
|
||||
</button>
|
||||
|
||||
{/* User Auth Profile Button */}
|
||||
{user ? (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setProfileDropdownOpen(!profileDropdownOpen)}
|
||||
className="flex items-center gap-2 px-2.5 py-1 bg-stone-800/90 hover:bg-stone-750 border border-stone-700 rounded-xl text-xs text-stone-200 transition"
|
||||
>
|
||||
{user.avatarUrl ? (
|
||||
<img src={user.avatarUrl} alt={user.name} className="w-5 h-5 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-emerald-600 text-[10px] font-bold text-white flex items-center justify-center">
|
||||
{user.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="hidden sm:flex flex-col text-left leading-tight">
|
||||
<span className="font-semibold text-[11px] truncate max-w-[90px]">{user.name}</span>
|
||||
<span className="text-[10px] text-emerald-400 font-mono">Hcp {user.handicapIndex}</span>
|
||||
</div>
|
||||
<ChevronDown className="w-3 h-3 text-stone-400" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{profileDropdownOpen && (
|
||||
<div className="absolute right-0 mt-2 w-64 bg-stone-900 border border-stone-800 rounded-2xl shadow-2xl p-3 z-50 animate-fadeIn space-y-2">
|
||||
<div className="px-2 py-1.5 border-b border-stone-800/80">
|
||||
<p className="font-bold text-xs text-stone-100">{user.name}</p>
|
||||
<p className="text-[10px] text-stone-400 font-mono">@{user.username}</p>
|
||||
<p className="text-[10px] text-stone-500 mt-0.5">{user.homeClub}</p>
|
||||
</div>
|
||||
|
||||
<div className="px-2 py-1 flex items-center justify-between text-xs">
|
||||
<span className="text-stone-400">Handicap Index:</span>
|
||||
<span className="font-mono font-bold text-emerald-400">{user.handicapIndex}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={async () => {
|
||||
await updateProfile({ isPublic: !user.isPublic });
|
||||
}}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-left text-xs text-stone-300 hover:bg-stone-800 flex items-center justify-between transition"
|
||||
>
|
||||
<span className="flex items-center gap-1.5">
|
||||
{user.isPublic ? <Eye className="w-3.5 h-3.5 text-emerald-400" /> : <EyeOff className="w-3.5 h-3.5 text-stone-400" />}
|
||||
Public Stats:
|
||||
</span>
|
||||
<span className={`font-semibold text-[11px] ${user.isPublic ? 'text-emerald-400' : 'text-stone-500'}`}>
|
||||
{user.isPublic ? 'Enabled' : 'Private'}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileDropdownOpen(false);
|
||||
onOpenCommunity();
|
||||
}}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-left text-xs text-stone-300 hover:bg-stone-800 flex items-center gap-2 transition"
|
||||
>
|
||||
<Users className="w-3.5 h-3.5 text-amber-400" />
|
||||
View Community Leaderboard
|
||||
</button>
|
||||
|
||||
<div className="pt-1 border-t border-stone-800">
|
||||
<button
|
||||
onClick={() => {
|
||||
setProfileDropdownOpen(false);
|
||||
logout();
|
||||
}}
|
||||
className="w-full px-2 py-1.5 rounded-lg text-left text-xs text-rose-400 hover:bg-rose-500/10 flex items-center gap-2 transition"
|
||||
>
|
||||
<LogOut className="w-3.5 h-3.5" />
|
||||
Sign Out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={onOpenAuth}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-stone-800 hover:bg-stone-700 text-stone-200 border border-stone-700 rounded-xl text-xs font-semibold shadow-sm transition"
|
||||
>
|
||||
<UserIcon className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Sign In</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Mobile Bottom Tab Bar (Thumb Zone Ergonomics) */}
|
||||
<div className="md:hidden fixed bottom-0 left-0 right-0 z-40 bg-stone-900/95 backdrop-blur-lg border-t border-stone-800">
|
||||
<div className="grid grid-cols-4 items-center h-16 max-w-lg mx-auto px-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('hole')}
|
||||
className={`flex flex-col items-center justify-center py-1 transition-colors ${
|
||||
activeTab === 'hole' ? 'text-emerald-400 font-semibold' : 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<Flag className="w-5 h-5" />
|
||||
<span className="text-[10px] mt-1">Hole Entry</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('scorecard')}
|
||||
className={`flex flex-col items-center justify-center py-1 transition-colors ${
|
||||
activeTab === 'scorecard' ? 'text-emerald-400 font-semibold' : 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<Table className="w-5 h-5" />
|
||||
<span className="text-[10px] mt-1">Scorecard</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
className={`flex flex-col items-center justify-center py-1 transition-colors ${
|
||||
activeTab === 'analytics' ? 'text-emerald-400 font-semibold' : 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<BarChart3 className="w-5 h-5" />
|
||||
<span className="text-[10px] mt-1">Analytics</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setActiveTab('settings')}
|
||||
className={`flex flex-col items-center justify-center py-1 transition-colors ${
|
||||
activeTab === 'settings' ? 'text-emerald-400 font-semibold' : 'text-stone-400 hover:text-stone-200'
|
||||
}`}
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
<span className="text-[10px] mt-1">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,905 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
X,
|
||||
Play,
|
||||
MapPin,
|
||||
Search,
|
||||
Globe,
|
||||
Plus,
|
||||
Copy,
|
||||
Trash2,
|
||||
Loader2,
|
||||
ExternalLink,
|
||||
Sparkles,
|
||||
Bookmark,
|
||||
Sun,
|
||||
CloudSun,
|
||||
Cloud,
|
||||
CloudRain,
|
||||
CloudLightning,
|
||||
CloudSnow,
|
||||
CloudFog,
|
||||
CloudDrizzle,
|
||||
Wind as WindIcon,
|
||||
Thermometer,
|
||||
Droplets,
|
||||
Compass,
|
||||
RefreshCw,
|
||||
Navigation,
|
||||
AlertCircle,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import { Course, Round, TrackingSettings, WeatherForecast } from '../types/golf';
|
||||
import { DEFAULT_COURSES } from '../data/courses';
|
||||
import { createEmptyHoleScore } from '../utils/golfStats';
|
||||
import {
|
||||
searchGolfCoursesApi,
|
||||
fetchCourseDetailsApi,
|
||||
ApiGolfCourseSearchResult,
|
||||
} from '../services/golfApi';
|
||||
import {
|
||||
fetchWeatherFromOpenMeteo,
|
||||
getUserGpsPosition,
|
||||
getCourseCoordinates,
|
||||
} from '../services/weatherService';
|
||||
|
||||
interface NewRoundModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onStartRound: (round: Round) => void;
|
||||
currentSettings: TrackingSettings;
|
||||
courses: Course[];
|
||||
onOpenCourseEditor: (baseCourse?: Course | null) => void;
|
||||
onDeleteCustomCourse: (courseId: string) => void;
|
||||
onCourseImported: (course: Course) => void;
|
||||
}
|
||||
|
||||
export const NewRoundModal: React.FC<NewRoundModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onStartRound,
|
||||
currentSettings,
|
||||
courses,
|
||||
onOpenCourseEditor,
|
||||
onDeleteCustomCourse,
|
||||
onCourseImported,
|
||||
}) => {
|
||||
const [selectedCourseId, setSelectedCourseId] = useState<string>(
|
||||
courses[0]?.id || DEFAULT_COURSES[0].id
|
||||
);
|
||||
const [teeBox, setTeeBox] = useState<string>('Tournament Blue');
|
||||
const [holesCount, setHolesCount] = useState<18 | 9>(18);
|
||||
const [handicap, setHandicap] = useState<number>(10);
|
||||
const [weather, setWeather] = useState<string>('Sunny · 22°C');
|
||||
const [wind, setWind] = useState<'Calm' | 'Breezy' | 'Strong' | 'Gusty'>('Breezy');
|
||||
|
||||
// Real-time GPS & Weather Forecast state
|
||||
const [weatherForecast, setWeatherForecast] = useState<WeatherForecast | null>(null);
|
||||
const [isWeatherLoading, setIsWeatherLoading] = useState(false);
|
||||
const [weatherError, setWeatherError] = useState<string | null>(null);
|
||||
const [weatherSource, setWeatherSource] = useState<'gps' | 'course'>('gps');
|
||||
const [showManualWeather, setShowManualWeather] = useState(false);
|
||||
|
||||
// Course Source Mode: 'presets' | 'api' | 'custom'
|
||||
const [courseTab, setCourseTab] = useState<'presets' | 'api' | 'custom'>('presets');
|
||||
|
||||
// API Search State
|
||||
const [apiQuery, setApiQuery] = useState('');
|
||||
const [apiResults, setApiResults] = useState<ApiGolfCourseSearchResult[]>([]);
|
||||
const [isSearchingApi, setIsSearchingApi] = useState(false);
|
||||
const [apiError, setApiError] = useState<string | null>(null);
|
||||
const [importingId, setImportingId] = useState<string | null>(null);
|
||||
|
||||
const selectedCourse: Course =
|
||||
courses.find((c) => c.id === selectedCourseId) || courses[0] || DEFAULT_COURSES[0];
|
||||
|
||||
const loadWeather = async (source: 'gps' | 'course' = weatherSource) => {
|
||||
setIsWeatherLoading(true);
|
||||
setWeatherError(null);
|
||||
setWeatherSource(source);
|
||||
|
||||
try {
|
||||
if (source === 'gps') {
|
||||
const coords = await getUserGpsPosition();
|
||||
const forecast = await fetchWeatherFromOpenMeteo(
|
||||
coords.lat,
|
||||
coords.lng,
|
||||
`GPS (${coords.lat.toFixed(3)}°, ${coords.lng.toFixed(3)}°)`,
|
||||
'gps'
|
||||
);
|
||||
setWeatherForecast(forecast);
|
||||
setWeather(`${forecast.temperature}°C · ${forecast.conditionText} (${forecast.precipitationProbability}% precip)`);
|
||||
setWind(forecast.windCategory);
|
||||
} else {
|
||||
const courseCoords = getCourseCoordinates(selectedCourse);
|
||||
const forecast = await fetchWeatherFromOpenMeteo(
|
||||
courseCoords.lat,
|
||||
courseCoords.lng,
|
||||
courseCoords.label,
|
||||
'course'
|
||||
);
|
||||
setWeatherForecast(forecast);
|
||||
setWeather(`${forecast.temperature}°C · ${forecast.conditionText} (${forecast.precipitationProbability}% precip)`);
|
||||
setWind(forecast.windCategory);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (source === 'gps') {
|
||||
// Fallback to course coordinates gracefully
|
||||
try {
|
||||
const courseCoords = getCourseCoordinates(selectedCourse);
|
||||
const fallbackForecast = await fetchWeatherFromOpenMeteo(
|
||||
courseCoords.lat,
|
||||
courseCoords.lng,
|
||||
courseCoords.label,
|
||||
'course'
|
||||
);
|
||||
setWeatherForecast(fallbackForecast);
|
||||
setWeatherSource('course');
|
||||
setWeather(`${fallbackForecast.temperature}°C · ${fallbackForecast.conditionText} (${fallbackForecast.precipitationProbability}% precip)`);
|
||||
setWind(fallbackForecast.windCategory);
|
||||
setWeatherError(
|
||||
err?.message ? `GPS unavailable: ${err.message}. Showing course forecast.` : 'GPS unavailable. Showing course forecast.'
|
||||
);
|
||||
return;
|
||||
} catch (fallbackErr: any) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
setWeatherError(err?.message || 'Failed to retrieve live forecast.');
|
||||
} finally {
|
||||
setIsWeatherLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadWeather('gps');
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && weatherSource === 'course') {
|
||||
loadWeather('course');
|
||||
}
|
||||
}, [selectedCourseId]);
|
||||
|
||||
const handleStart = () => {
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const initialScores: Record<number, ReturnType<typeof createEmptyHoleScore>> = {};
|
||||
|
||||
selectedCourse.holes.forEach((h) => {
|
||||
initialScores[h.holeNumber] = createEmptyHoleScore(h.holeNumber, h.par);
|
||||
});
|
||||
|
||||
const newRound: Round = {
|
||||
id: `round-${Date.now()}`,
|
||||
date: today,
|
||||
courseId: selectedCourse.id,
|
||||
courseName: selectedCourse.name,
|
||||
teeName: teeBox,
|
||||
coursePar: selectedCourse.parTotal,
|
||||
holesPlayed: holesCount,
|
||||
nineType: holesCount === 18 ? 'all' : 'front',
|
||||
scores: initialScores,
|
||||
isCompleted: false,
|
||||
settingsSnapshot: { ...currentSettings },
|
||||
playerHandicap: handicap,
|
||||
weather,
|
||||
wind,
|
||||
weatherData: weatherForecast || undefined,
|
||||
};
|
||||
|
||||
onStartRound(newRound);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleApiSearch = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!apiQuery.trim()) return;
|
||||
|
||||
setIsSearchingApi(true);
|
||||
setApiError(null);
|
||||
try {
|
||||
const results = await searchGolfCoursesApi(apiQuery.trim());
|
||||
setApiResults(results);
|
||||
if (results.length === 0) {
|
||||
setApiError('No courses found matching that search. Try another name or location.');
|
||||
}
|
||||
} catch (err: any) {
|
||||
setApiError(err?.message || 'Failed to search golf courses API.');
|
||||
} finally {
|
||||
setIsSearchingApi(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectApiCourse = async (apiCourse: ApiGolfCourseSearchResult) => {
|
||||
setImportingId(apiCourse.id);
|
||||
setApiError(null);
|
||||
try {
|
||||
const fullCourse = await fetchCourseDetailsApi(apiCourse.id);
|
||||
onCourseImported(fullCourse);
|
||||
setSelectedCourseId(fullCourse.id);
|
||||
setCourseTab('presets'); // Return to course list with it selected
|
||||
} catch (err: any) {
|
||||
setApiError('Failed to download course scorecard details. You can create it as a custom course.');
|
||||
} finally {
|
||||
setImportingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const customCourses = courses.filter((c) => c.isCustom);
|
||||
const presetCourses = courses.filter((c) => !c.isCustom);
|
||||
|
||||
const teeOptions = [
|
||||
{ name: 'Championship Black', color: 'bg-stone-900 border-stone-700 text-white' },
|
||||
{ name: 'Tournament Blue', color: 'bg-blue-900/60 border-blue-500/80 text-blue-200' },
|
||||
{ name: 'Member White', color: 'bg-stone-100 border-stone-300 text-stone-900' },
|
||||
{ name: 'Forward Red', color: 'bg-rose-900/60 border-rose-500/80 text-rose-200' },
|
||||
];
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-black/80 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-2xl w-full p-5 sm:p-6 shadow-2xl relative max-h-[92vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-stone-800 shrink-0">
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-white">Start New Round</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
Select course from presets, public API directory, or build a custom course
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Content */}
|
||||
<div className="space-y-5 my-4 overflow-y-auto flex-1 pr-1.5">
|
||||
{/* Course Selector Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<label className="text-xs font-bold uppercase tracking-wider text-stone-300">
|
||||
Choose Golf Course:
|
||||
</label>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCourseEditor(null)}
|
||||
className="flex items-center gap-1 text-emerald-400 hover:text-emerald-300 font-semibold px-2 py-0.5 rounded-lg hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>+ New Custom Course</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Mode Tabs */}
|
||||
<div className="grid grid-cols-3 gap-1.5 bg-stone-850 p-1 rounded-2xl border border-stone-800 mb-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCourseTab('presets')}
|
||||
className={`py-2 px-2 rounded-xl text-xs font-bold transition-all ${
|
||||
courseTab === 'presets'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Presets & Loaded ({presetCourses.length})
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCourseTab('api')}
|
||||
className={`py-2 px-2 rounded-xl text-xs font-bold flex items-center justify-center gap-1.5 transition-all ${
|
||||
courseTab === 'api'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Globe className="w-3.5 h-3.5" />
|
||||
<span>Public API (32k+)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCourseTab('custom')}
|
||||
className={`py-2 px-2 rounded-xl text-xs font-bold transition-all ${
|
||||
courseTab === 'custom'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Custom Courses ({customCourses.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* TAB 1: PRESETS */}
|
||||
{courseTab === 'presets' && (
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
{presetCourses.map((course) => {
|
||||
const isSelected = selectedCourseId === course.id;
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
onClick={() => setSelectedCourseId(course.id)}
|
||||
className={`p-3 rounded-2xl border cursor-pointer transition-all flex items-center justify-between gap-3 ${
|
||||
isSelected
|
||||
? 'bg-emerald-950/40 border-emerald-500 text-white shadow-md'
|
||||
: 'bg-stone-850 border-stone-800 text-stone-300 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-white">{course.name}</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-stone-700 text-stone-300">
|
||||
Par {course.parTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-stone-400 mt-0.5">
|
||||
<MapPin className="w-3 h-3 text-stone-500" />
|
||||
<span>{course.location}</span>
|
||||
<span>·</span>
|
||||
<span>{course.holes.length} Holes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modify/Duplicate Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCourseEditor(course);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-stone-400 hover:text-white hover:bg-stone-700 transition-colors text-xs flex items-center gap-1"
|
||||
title="Duplicate and edit this course"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
<span className="hidden sm:inline">Modify</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 2: PUBLIC GOLF API SEARCH */}
|
||||
{courseTab === 'api' && (
|
||||
<div className="space-y-3 bg-stone-850 p-3.5 rounded-2xl border border-stone-800">
|
||||
<form onSubmit={handleApiSearch} className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="w-4 h-4 text-stone-400 absolute left-3 top-3" />
|
||||
<input
|
||||
type="text"
|
||||
value={apiQuery}
|
||||
onChange={(e) => setApiQuery(e.target.value)}
|
||||
placeholder="Search 32,000+ courses (e.g. Pebble, Bethpage, St Andrews, Torrey)"
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl pl-9 pr-3 py-2 text-xs text-white placeholder-stone-500 focus:outline-none focus:border-emerald-500 font-medium"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSearchingApi || !apiQuery.trim()}
|
||||
className="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-40 text-white text-xs font-bold rounded-xl transition-all flex items-center gap-1.5 shrink-0"
|
||||
>
|
||||
{isSearchingApi ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Search className="w-3.5 h-3.5" />}
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{/* Popular Search Suggestions */}
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-stone-400 overflow-x-auto pb-1">
|
||||
<span className="text-stone-500 shrink-0">Try:</span>
|
||||
{['Pebble Beach', 'Augusta', 'Pinehurst', 'Torrey Pines', 'St Andrews'].map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setApiQuery(name);
|
||||
searchGolfCoursesApi(name).then(setApiResults).catch(() => {});
|
||||
}}
|
||||
className="px-2 py-0.5 rounded-lg bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white shrink-0 transition-colors"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{apiError && (
|
||||
<div className="p-2.5 rounded-xl bg-rose-950/40 border border-rose-800/60 text-xs text-rose-300">
|
||||
{apiError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Search Results List */}
|
||||
<div className="space-y-2 max-h-52 overflow-y-auto pr-1">
|
||||
{apiResults.map((result) => {
|
||||
const isImporting = importingId === result.id;
|
||||
return (
|
||||
<div
|
||||
key={result.id}
|
||||
className="p-3 rounded-xl bg-stone-800/90 border border-stone-700 flex items-center justify-between gap-3 hover:border-emerald-500/60 transition-colors"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-bold text-xs text-white truncate">
|
||||
{result.name || result.course_name}
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 flex items-center gap-2 mt-0.5">
|
||||
<span>
|
||||
{[result.city, result.state].filter(Boolean).join(', ') || 'Global Course'}
|
||||
</span>
|
||||
<span>·</span>
|
||||
<span className="font-mono text-emerald-400">
|
||||
Par {result.par || 72}
|
||||
</span>
|
||||
{result.holes && <span>· {result.holes} Holes</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSelectApiCourse(result)}
|
||||
disabled={isImporting}
|
||||
className="px-3 py-1.5 bg-emerald-600 hover:bg-emerald-500 disabled:opacity-50 text-white text-xs font-bold rounded-lg transition-colors flex items-center gap-1 shrink-0"
|
||||
>
|
||||
{isImporting ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
) : (
|
||||
<Bookmark className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span>Select Course</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* TAB 3: CUSTOM COURSES */}
|
||||
{courseTab === 'custom' && (
|
||||
<div className="space-y-2 max-h-56 overflow-y-auto pr-1">
|
||||
{customCourses.length === 0 ? (
|
||||
<div className="p-6 text-center bg-stone-850 rounded-2xl border border-stone-800 text-stone-400">
|
||||
<p className="text-xs">No custom courses created yet.</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCourseEditor(null)}
|
||||
className="mt-2.5 px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl transition-colors inline-flex items-center gap-1.5"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Your First Custom Course</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
customCourses.map((course) => {
|
||||
const isSelected = selectedCourseId === course.id;
|
||||
return (
|
||||
<div
|
||||
key={course.id}
|
||||
onClick={() => setSelectedCourseId(course.id)}
|
||||
className={`p-3 rounded-2xl border cursor-pointer transition-all flex items-center justify-between gap-3 ${
|
||||
isSelected
|
||||
? 'bg-emerald-950/40 border-emerald-500 text-white shadow-md'
|
||||
: 'bg-stone-850 border-stone-800 text-stone-300 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-sm text-white">{course.name}</span>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.2 rounded bg-emerald-500/20 text-emerald-300">
|
||||
Custom
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-stone-700 text-stone-300">
|
||||
Par {course.parTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-stone-400 mt-0.5">
|
||||
<MapPin className="w-3 h-3 text-stone-500" />
|
||||
<span>{course.location}</span>
|
||||
<span>·</span>
|
||||
<span>{course.holes.length} Holes</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCourseEditor(course);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-stone-400 hover:text-white hover:bg-stone-700 transition-colors text-xs"
|
||||
title="Edit course specifications"
|
||||
>
|
||||
<Copy className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteCustomCourse(course.id);
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-stone-400 hover:text-rose-400 hover:bg-stone-700 transition-colors text-xs"
|
||||
title="Delete custom course"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected Course Visual Confirmation */}
|
||||
<div className="bg-stone-850 p-3 rounded-2xl border border-stone-800 flex items-center justify-between text-xs">
|
||||
<span className="text-stone-400">Active Course for Round:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white text-sm">{selectedCourse.name}</span>
|
||||
<span className="font-mono text-emerald-400">
|
||||
Par {selectedCourse.parTotal} · {selectedCourse.holes.length} Holes
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tee Box Selection */}
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-stone-300 block mb-1.5">
|
||||
Tee Box:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{teeOptions.map((t) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onClick={() => setTeeBox(t.name)}
|
||||
className={`py-2 px-3 rounded-xl border text-xs font-semibold transition-all ${
|
||||
teeBox === t.name
|
||||
? `${t.color} ring-2 ring-emerald-400 shadow-sm font-bold`
|
||||
: 'bg-stone-850 border-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
{t.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Holes & Handicap */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-stone-300 block mb-1.5">
|
||||
Holes to Play:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHolesCount(18)}
|
||||
className={`py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
holesCount === 18
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Full 18
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setHolesCount(9)}
|
||||
className={`py-2 rounded-xl text-xs font-bold transition-all ${
|
||||
holesCount === 9
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
9 Holes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold uppercase tracking-wider text-stone-300 block mb-1.5">
|
||||
Player Handicap:
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="54"
|
||||
value={handicap}
|
||||
onChange={(e) => setHandicap(Number(e.target.value))}
|
||||
className="w-full bg-stone-800 border border-stone-700 rounded-xl px-3 py-2 text-xs text-white font-mono font-bold focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Real-Time Weather & Wind Forecast from Open Weather API */}
|
||||
<div className="bg-stone-850/90 border border-stone-800 rounded-2xl p-4 space-y-3 relative overflow-hidden">
|
||||
{/* Header & Source Selector */}
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-stone-800/80 pb-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-7 h-7 rounded-lg bg-amber-500/10 border border-amber-500/20 flex items-center justify-center text-amber-400">
|
||||
<CloudSun className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold text-white uppercase tracking-wider">
|
||||
Live Weather Forecast
|
||||
</span>
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.2 rounded bg-emerald-500/15 text-emerald-400 font-semibold">
|
||||
Open-Meteo API
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400">
|
||||
Live meteorological conditions based on {weatherSource === 'gps' ? 'your current GPS position' : 'course coordinates'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Mode Toggles & Refresh */}
|
||||
<div className="flex items-center gap-1.5">
|
||||
<div className="bg-stone-800 p-0.5 rounded-xl flex items-center text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadWeather('gps')}
|
||||
disabled={isWeatherLoading}
|
||||
className={`px-2.5 py-1 rounded-lg font-semibold transition-all flex items-center gap-1 text-[11px] ${
|
||||
weatherSource === 'gps'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Navigation className="w-3 h-3" />
|
||||
<span>My GPS</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadWeather('course')}
|
||||
disabled={isWeatherLoading}
|
||||
className={`px-2.5 py-1 rounded-lg font-semibold transition-all flex items-center gap-1 text-[11px] ${
|
||||
weatherSource === 'course'
|
||||
? 'bg-emerald-600 text-white shadow-sm'
|
||||
: 'text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<MapPin className="w-3 h-3" />
|
||||
<span>Course GPS</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadWeather(weatherSource)}
|
||||
disabled={isWeatherLoading}
|
||||
className="p-1.5 rounded-lg bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white transition-colors"
|
||||
title="Refresh weather from Open Weather API"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${isWeatherLoading ? 'animate-spin text-emerald-400' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error banner if GPS was denied or unavailable */}
|
||||
{weatherError && (
|
||||
<div className="p-2.5 rounded-xl bg-amber-950/30 border border-amber-600/30 text-amber-300 text-xs flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 shrink-0 text-amber-400" />
|
||||
<span>{weatherError}</span>
|
||||
</div>
|
||||
{weatherSource === 'gps' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadWeather('course')}
|
||||
className="text-[11px] font-bold underline hover:text-white shrink-0"
|
||||
>
|
||||
Switch to Course GPS
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{isWeatherLoading && !weatherForecast && (
|
||||
<div className="py-6 flex flex-col items-center justify-center text-center text-xs text-stone-400 gap-2">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-emerald-400" />
|
||||
<span>Querying open meteorological satellite data for current GPS coordinates...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Weather Metrics Display */}
|
||||
{weatherForecast && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
{/* Metric 1: Current Temperature */}
|
||||
<div className="bg-stone-900/90 border border-stone-800 rounded-xl p-3 flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 block">
|
||||
Temperature
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-2xl font-black text-white font-mono">
|
||||
{weatherForecast.temperature}°C
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 font-mono">
|
||||
/ {weatherForecast.temperatureF}°F
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400 mt-0.5 flex items-center gap-1">
|
||||
<span>Feels like:</span>
|
||||
<span className="font-semibold text-stone-200">
|
||||
{weatherForecast.apparentTemperature ?? weatherForecast.temperature}°C
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
|
||||
<Thermometer className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 2: Wind Speed & Direction */}
|
||||
<div className="bg-stone-900/90 border border-stone-800 rounded-xl p-3 flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 block">
|
||||
Wind Speed & Heading
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className="text-2xl font-black text-cyan-400 font-mono">
|
||||
{weatherForecast.windSpeed}
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 font-mono">km/h</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-300 mt-0.5 flex items-center gap-1.5">
|
||||
<span className="font-bold text-cyan-300">
|
||||
{weatherForecast.windDirectionCompass} ({weatherForecast.windDirection}°)
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-stone-400">{weatherForecast.windCategory}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-cyan-500/10 text-cyan-400">
|
||||
<WindIcon className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric 3: Precipitation Probability */}
|
||||
<div className="bg-stone-900/90 border border-stone-800 rounded-xl p-3 flex items-start justify-between">
|
||||
<div>
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-stone-400 block">
|
||||
Precipitation Chance
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1.5 mt-0.5">
|
||||
<span className={`text-2xl font-black font-mono ${
|
||||
weatherForecast.precipitationProbability > 50
|
||||
? 'text-rose-400'
|
||||
: weatherForecast.precipitationProbability > 20
|
||||
? 'text-amber-400'
|
||||
: 'text-emerald-400'
|
||||
}`}>
|
||||
{weatherForecast.precipitationProbability}%
|
||||
</span>
|
||||
<span className="text-xs text-stone-400 font-mono">prob</span>
|
||||
</div>
|
||||
{/* Mini Bar */}
|
||||
<div className="w-28 bg-stone-800 h-1.5 rounded-full overflow-hidden mt-1.5">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
weatherForecast.precipitationProbability > 50
|
||||
? 'bg-rose-500'
|
||||
: weatherForecast.precipitationProbability > 20
|
||||
? 'bg-amber-400'
|
||||
: 'bg-emerald-500'
|
||||
}`}
|
||||
style={{ width: `${Math.max(5, weatherForecast.precipitationProbability)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 rounded-lg bg-blue-500/10 text-blue-400">
|
||||
<Droplets className="w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sub-banner: Sky condition + Geolocation origin */}
|
||||
<div className="bg-stone-900/60 rounded-xl px-3 py-2 border border-stone-800/60 flex flex-wrap items-center justify-between gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white flex items-center gap-1.5">
|
||||
<Sun className="w-3.5 h-3.5 text-amber-400" />
|
||||
{weatherForecast.conditionText}
|
||||
</span>
|
||||
{weatherForecast.relativeHumidity !== undefined && (
|
||||
<>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-stone-400">
|
||||
{weatherForecast.relativeHumidity}% humidity
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 text-stone-400 text-[11px]">
|
||||
<span className="flex items-center gap-1 font-mono">
|
||||
<MapPin className="w-3 h-3 text-emerald-400" />
|
||||
{weatherForecast.source === 'gps'
|
||||
? `GPS: ${weatherForecast.latitude.toFixed(3)}°, ${weatherForecast.longitude.toFixed(3)}°`
|
||||
: weatherForecast.locationLabel}
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-stone-500">Updated {weatherForecast.fetchedAt}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Manual Override & Notes Expandable */}
|
||||
<div className="pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowManualWeather(!showManualWeather)}
|
||||
className="text-xs text-stone-400 hover:text-stone-200 flex items-center gap-1 font-medium transition-colors"
|
||||
>
|
||||
<span>{showManualWeather ? 'Hide' : 'Show'} Custom Weather Notes & Manual Overrides</span>
|
||||
{showManualWeather ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
||||
</button>
|
||||
|
||||
{showManualWeather && (
|
||||
<div className="grid grid-cols-2 gap-3 mt-2.5 pt-2 border-t border-stone-800 animate-fade-in">
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wider text-stone-400 block mb-1">
|
||||
Custom Weather Description:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={weather}
|
||||
onChange={(e) => setWeather(e.target.value)}
|
||||
placeholder="e.g. 18°C · Partly Cloudy (0% precip)"
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-[11px] font-semibold uppercase tracking-wider text-stone-400 block mb-1">
|
||||
Wind Intensity Override:
|
||||
</label>
|
||||
<select
|
||||
value={wind}
|
||||
onChange={(e) => setWind(e.target.value as any)}
|
||||
className="w-full bg-stone-900 border border-stone-700 rounded-xl px-3 py-2 text-xs text-white focus:outline-none focus:border-emerald-500"
|
||||
>
|
||||
<option value="Calm">Calm (0-8 km/h)</option>
|
||||
<option value="Breezy">Breezy (9-20 km/h)</option>
|
||||
<option value="Strong">Strong (21-35 km/h)</option>
|
||||
<option value="Gusty">Gusty (36+ km/h)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modal Footer Actions */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-between gap-3 shrink-0">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 rounded-xl text-xs font-semibold text-stone-400 hover:text-white transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleStart}
|
||||
className="px-6 py-2.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold shadow-md shadow-emerald-950/40 flex items-center gap-1.5 transition-all"
|
||||
>
|
||||
<Play className="w-3.5 h-3.5 fill-current" />
|
||||
<span>Tee Off (Start Round)</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
import React from 'react';
|
||||
import { X, Calendar, MapPin, Trash2, CheckCircle2, RotateCcw } from 'lucide-react';
|
||||
import { Round } from '../types/golf';
|
||||
import { formatToPar } from '../utils/golfStats';
|
||||
import { SAMPLE_ROUND } from '../data/sampleRounds';
|
||||
|
||||
interface RoundsHistoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
rounds: Round[];
|
||||
currentRoundId: string;
|
||||
onSelectRound: (roundId: string) => void;
|
||||
onDeleteRound: (roundId: string) => void;
|
||||
onLoadSampleRound: () => void;
|
||||
}
|
||||
|
||||
export const RoundsHistoryModal: React.FC<RoundsHistoryModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
rounds,
|
||||
currentRoundId,
|
||||
onSelectRound,
|
||||
onDeleteRound,
|
||||
onLoadSampleRound,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/75 backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl max-w-xl w-full p-6 shadow-2xl relative max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between pb-4 border-b border-stone-800 shrink-0">
|
||||
<div>
|
||||
<h3 className="text-xl font-extrabold text-white">Rounds & History</h3>
|
||||
<p className="text-xs text-stone-400 mt-0.5">
|
||||
Review saved rounds or reload the sample tour analytics round
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-xl text-stone-400 hover:text-white hover:bg-stone-800 transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Rounds List */}
|
||||
<div className="space-y-3 my-4 overflow-y-auto flex-1 pr-1">
|
||||
{rounds.map((r) => {
|
||||
const isCurrent = r.id === currentRoundId;
|
||||
|
||||
// Calculate quick score
|
||||
let grossTotal = 0;
|
||||
let holesScored = 0;
|
||||
Object.values(r.scores).forEach((s) => {
|
||||
if (s.grossScore && s.grossScore > 0) {
|
||||
grossTotal += s.grossScore;
|
||||
holesScored++;
|
||||
}
|
||||
});
|
||||
const diff = grossTotal - r.coursePar;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={r.id}
|
||||
className={`p-4 rounded-2xl border transition-all flex items-center justify-between gap-4 ${
|
||||
isCurrent
|
||||
? 'bg-emerald-950/30 border-emerald-500/80'
|
||||
: 'bg-stone-850 border-stone-800 hover:bg-stone-800/80'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
onSelectRound(r.id);
|
||||
onClose();
|
||||
}}
|
||||
className="cursor-pointer flex-1"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-bold text-white text-sm">{r.courseName}</span>
|
||||
{isCurrent && (
|
||||
<span className="text-[10px] font-bold px-2 py-0.5 rounded bg-emerald-500/20 text-emerald-400">
|
||||
Active
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3 text-xs text-stone-400 mt-1">
|
||||
<div className="flex items-center gap-1">
|
||||
<Calendar className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.date}</span>
|
||||
</div>
|
||||
<span>·</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<MapPin className="w-3.5 h-3.5 text-stone-500" />
|
||||
<span>{r.teeName}</span>
|
||||
</div>
|
||||
{r.weatherData ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-amber-300 font-mono">
|
||||
🌡️ {r.weatherData.temperature}°C · 💨 {r.weatherData.windSpeed} km/h · 🌧️ {r.weatherData.precipitationProbability}%
|
||||
</span>
|
||||
</>
|
||||
) : r.weather ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="text-[11px] text-stone-400">
|
||||
{r.weather}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Score & Actions */}
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="text-right">
|
||||
<div className="text-base font-extrabold font-mono text-white">
|
||||
{grossTotal > 0 ? grossTotal : '—'}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs font-mono font-semibold ${
|
||||
diff < 0 ? 'text-emerald-400' : diff > 0 ? 'text-rose-400' : 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{holesScored > 0 ? formatToPar(diff) : 'In progress'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rounds.length > 1 && (
|
||||
<button
|
||||
onClick={() => onDeleteRound(r.id)}
|
||||
className="p-2 text-stone-500 hover:text-rose-400 hover:bg-stone-800 rounded-xl transition-colors"
|
||||
title="Delete round"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer with Sample Round restore */}
|
||||
<div className="pt-4 border-t border-stone-800 flex items-center justify-between shrink-0">
|
||||
<button
|
||||
onClick={() => {
|
||||
onLoadSampleRound();
|
||||
onClose();
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white text-xs font-semibold transition-colors"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5 text-emerald-400" />
|
||||
<span>Load Sample 18-Hole Round</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-5 py-2 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold transition-colors"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,662 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Download,
|
||||
Share2,
|
||||
Check,
|
||||
Edit3,
|
||||
} from 'lucide-react';
|
||||
import { Course, Round, TrackingSettings } from '../types/golf';
|
||||
import { calculateRoundStats, formatToPar } from '../utils/golfStats';
|
||||
|
||||
interface ScorecardProps {
|
||||
round: Round;
|
||||
course: Course;
|
||||
settings: TrackingSettings;
|
||||
onEditHole: (holeNumber: number) => void;
|
||||
}
|
||||
|
||||
export const Scorecard: React.FC<ScorecardProps> = ({
|
||||
round,
|
||||
course,
|
||||
settings,
|
||||
onEditHole,
|
||||
}) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const stats = calculateRoundStats(round, course);
|
||||
|
||||
// Split holes into Front 9 and Back 9
|
||||
const frontHoles = course.holes.filter((h) => h.holeNumber <= 9);
|
||||
const backHoles = course.holes.filter((h) => h.holeNumber > 9);
|
||||
|
||||
const frontPar = frontHoles.reduce((sum, h) => sum + h.par, 0);
|
||||
const backPar = backHoles.reduce((sum, h) => sum + h.par, 0);
|
||||
const totalPar = frontPar + backPar;
|
||||
|
||||
const frontYards = frontHoles.reduce((sum, h) => sum + h.yardage, 0);
|
||||
const backYards = backHoles.reduce((sum, h) => sum + h.yardage, 0);
|
||||
const totalYards = frontYards + backYards;
|
||||
|
||||
// Calculate row sub-sums
|
||||
const getSubTotal = (
|
||||
holes: typeof course.holes,
|
||||
getter: (score: typeof round.scores[number]) => number | null
|
||||
) => {
|
||||
return holes.reduce((sum, h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
if (!s) return sum;
|
||||
const val = getter(s);
|
||||
return sum + (val || 0);
|
||||
}, 0);
|
||||
};
|
||||
|
||||
const frontScore = getSubTotal(frontHoles, (s) => s.grossScore);
|
||||
const backScore = getSubTotal(backHoles, (s) => s.grossScore);
|
||||
const totalScore = frontScore + backScore;
|
||||
|
||||
const frontPutts = getSubTotal(frontHoles, (s) => s.putts);
|
||||
const backPutts = getSubTotal(backHoles, (s) => s.putts);
|
||||
const totalPutts = frontPutts + backPutts;
|
||||
|
||||
const frontForegreen = getSubTotal(frontHoles, (s) => s.foregreenPutts);
|
||||
const backForegreen = getSubTotal(backHoles, (s) => s.foregreenPutts);
|
||||
const totalForegreen = frontForegreen + backForegreen;
|
||||
|
||||
const frontPenalties = getSubTotal(frontHoles, (s) =>
|
||||
s.penalties?.reduce((pSum, p) => pSum + p.strokes, 0) || 0
|
||||
);
|
||||
const backPenalties = getSubTotal(backHoles, (s) =>
|
||||
s.penalties?.reduce((pSum, p) => pSum + p.strokes, 0) || 0
|
||||
);
|
||||
const totalPenalties = frontPenalties + backPenalties;
|
||||
|
||||
// Helper for scoring badge appearance
|
||||
const renderScoreCell = (score: number | null | undefined, par: number) => {
|
||||
if (!score || score <= 0) return <span className="text-stone-500">—</span>;
|
||||
const diff = score - par;
|
||||
|
||||
if (score === 1) {
|
||||
// Hole in one
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full bg-amber-400 text-stone-900 font-extrabold ring-2 ring-white">
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (diff <= -2) {
|
||||
// Eagle or better: Double circle / gold
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full border-2 border-amber-400 bg-amber-500/20 text-amber-300 font-bold ring-1 ring-amber-400">
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (diff === -1) {
|
||||
// Birdie: Single circle / emerald
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 rounded-full border border-emerald-400 bg-emerald-500/20 text-emerald-300 font-bold">
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (diff === 0) {
|
||||
// Par: clean number
|
||||
return <span className="font-semibold text-stone-200">{score}</span>;
|
||||
}
|
||||
if (diff === 1) {
|
||||
// Bogey: square
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 border border-stone-500 bg-stone-800 text-stone-200 font-semibold rounded-sm">
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
// Double bogey or worse: double square
|
||||
return (
|
||||
<span className="inline-flex items-center justify-center w-7 h-7 border-2 border-rose-500/80 bg-rose-950/40 text-rose-300 font-bold rounded-sm">
|
||||
{score}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const handleShareSummary = () => {
|
||||
const summaryText = `🏌️ PinPoint Golf Scorecard\nCourse: ${course.name} (${course.tee})\nScore: ${totalScore} (${stats.toParDisplay})\nPutts: ${totalPutts} | Foregreen Putts: ${totalForegreen}\nFIR: ${stats.firPercentage}% | GIR: ${stats.girPercentage}%\nBunkers: ${stats.totalBunkerHoles} | Penalties: ${stats.totalPenaltyStrokes}`;
|
||||
navigator.clipboard.writeText(summaryText);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleDownloadCSV = () => {
|
||||
let csv = 'Hole,Par,Yardage,HCP,Score,ToPar,FIR,GIR,Putts,Foregreen,Bunker,Penalties\n';
|
||||
course.holes.forEach((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
const score = s?.grossScore ?? '';
|
||||
const toPar = s?.grossScore ? formatToPar(s.grossScore - h.par) : '';
|
||||
const fir = s?.fir ?? '';
|
||||
const gir = s?.gir ?? '';
|
||||
const putts = s?.putts ?? '';
|
||||
const foregreen = s?.foregreenPutts ?? 0;
|
||||
const bunker = s?.bunker?.hit ? (s.bunker.fairwayBunker ? 'FW' : '') + (s.bunker.greensideBunker ? 'GS' : '') : 'No';
|
||||
const pens = s?.penalties?.map((p) => p.type).join(';') ?? '';
|
||||
csv += `${h.holeNumber},${h.par},${h.yardage},${h.handicapIndex},${score},${toPar},${fir},${gir},${putts},${foregreen},${bunker},${pens}\n`;
|
||||
});
|
||||
|
||||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.setAttribute('download', `golf-scorecard-${round.date}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
// Reusable 9-hole table renderer
|
||||
const renderNineTable = (
|
||||
title: string,
|
||||
holes: typeof course.holes,
|
||||
subScore: number,
|
||||
subPar: number,
|
||||
subYards: number,
|
||||
subPutts: number,
|
||||
subForegreen: number,
|
||||
subPens: number
|
||||
) => {
|
||||
return (
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl overflow-hidden shadow-md">
|
||||
<div className="bg-stone-800/80 px-4 py-3 border-b border-stone-800 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-bold text-white text-sm sm:text-base">{title}</h3>
|
||||
<span className="text-xs text-stone-400">
|
||||
Par {subPar} · {subYards} {settings.distanceUnit}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs font-mono font-bold text-emerald-400">
|
||||
Subtotal: {subScore > 0 ? subScore : '—'} ({formatToPar(subScore - subPar)})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-center text-xs font-mono tabular-nums border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-stone-950/60 text-stone-400 border-b border-stone-800">
|
||||
<th className="py-2.5 px-3 text-left font-sans font-semibold text-stone-300 w-24">
|
||||
Hole
|
||||
</th>
|
||||
{holes.map((h) => (
|
||||
<th
|
||||
key={h.holeNumber}
|
||||
onClick={() => onEditHole(h.holeNumber)}
|
||||
className="py-2.5 px-1 sm:px-2 min-w-[36px] sm:min-w-[42px] cursor-pointer hover:bg-stone-800 hover:text-white transition-colors"
|
||||
>
|
||||
{h.holeNumber}
|
||||
</th>
|
||||
))}
|
||||
<th className="py-2.5 px-2 bg-stone-800/50 text-white font-bold min-w-[48px]">
|
||||
{title === 'Front 9' ? 'OUT' : 'IN'}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-stone-800/60 text-stone-300">
|
||||
{/* Par Row */}
|
||||
<tr className="bg-stone-900/40 text-stone-400">
|
||||
<td className="py-2 px-3 text-left font-sans font-medium">Par</td>
|
||||
{holes.map((h) => (
|
||||
<td key={h.holeNumber} className="py-2 px-1">
|
||||
{h.par}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-2 px-2 bg-stone-800/40 text-white font-bold">{subPar}</td>
|
||||
</tr>
|
||||
|
||||
{/* Yardage Row */}
|
||||
<tr className="text-stone-500 text-[11px]">
|
||||
<td className="py-1.5 px-3 text-left font-sans">{settings.distanceUnit}</td>
|
||||
{holes.map((h) => (
|
||||
<td key={h.holeNumber} className="py-1.5 px-1">
|
||||
{h.yardage}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5 px-2 bg-stone-800/40 font-bold">{subYards}</td>
|
||||
</tr>
|
||||
|
||||
{/* Handicap Index Row */}
|
||||
<tr className="text-stone-500 text-[11px]">
|
||||
<td className="py-1.5 px-3 text-left font-sans">HCP Index</td>
|
||||
{holes.map((h) => (
|
||||
<td key={h.holeNumber} className="py-1.5 px-1 text-stone-400">
|
||||
{h.handicapIndex}
|
||||
</td>
|
||||
))}
|
||||
<td className="py-1.5 px-2 bg-stone-800/40">—</td>
|
||||
</tr>
|
||||
|
||||
{/* Gross Score Row (Highlight) */}
|
||||
<tr className="bg-stone-850 font-bold border-y border-stone-700">
|
||||
<td className="py-3 px-3 text-left font-sans text-white flex items-center gap-1.5">
|
||||
<span>Score</span>
|
||||
<Edit3 className="w-3 h-3 text-stone-500" />
|
||||
</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
return (
|
||||
<td
|
||||
key={h.holeNumber}
|
||||
onClick={() => onEditHole(h.holeNumber)}
|
||||
className="py-2 px-1 cursor-pointer hover:bg-stone-750 transition-colors"
|
||||
title={`Edit Hole ${h.holeNumber}`}
|
||||
>
|
||||
{renderScoreCell(s?.grossScore, h.par)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-3 px-2 bg-stone-800 text-white font-extrabold text-sm text-emerald-400">
|
||||
{subScore > 0 ? subScore : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Score to Par row */}
|
||||
<tr className="text-[11px] text-stone-400">
|
||||
<td className="py-1.5 px-3 text-left font-sans">To Par</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
if (!s || !s.grossScore) return <td key={h.holeNumber}>—</td>;
|
||||
const diff = s.grossScore - h.par;
|
||||
return (
|
||||
<td
|
||||
key={h.holeNumber}
|
||||
className={`py-1.5 px-1 font-semibold ${
|
||||
diff < 0
|
||||
? 'text-emerald-400'
|
||||
: diff > 0
|
||||
? 'text-rose-400'
|
||||
: 'text-stone-400'
|
||||
}`}
|
||||
>
|
||||
{formatToPar(diff)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-1.5 px-2 bg-stone-800/40 font-bold text-stone-300">
|
||||
{formatToPar(subScore - subPar)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* Fairway in Regulation (FIR) */}
|
||||
{settings.trackFIR && (
|
||||
<tr className="text-stone-300 text-xs">
|
||||
<td className="py-2 px-3 text-left font-sans text-stone-400">FIR</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
if (h.par === 3) return <td key={h.holeNumber} className="text-stone-600">—</td>;
|
||||
if (!s || !s.fir) return <td key={h.holeNumber} className="text-stone-600">—</td>;
|
||||
let symbol = '—';
|
||||
let color = 'text-stone-500';
|
||||
if (s.fir === 'hit') {
|
||||
symbol = '✓';
|
||||
color = 'text-emerald-400 font-bold';
|
||||
} else if (s.fir === 'left') {
|
||||
symbol = '⬅';
|
||||
color = 'text-amber-400';
|
||||
} else if (s.fir === 'right') {
|
||||
symbol = '➡';
|
||||
color = 'text-amber-400';
|
||||
} else if (s.fir === 'short') {
|
||||
symbol = '⬇';
|
||||
color = 'text-amber-400';
|
||||
}
|
||||
return (
|
||||
<td key={h.holeNumber} className={`py-2 px-1 ${color}`}>
|
||||
{symbol}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 text-stone-400">
|
||||
{holes.filter((h) => h.par > 3 && round.scores[h.holeNumber]?.fir === 'hit').length}/
|
||||
{holes.filter((h) => h.par > 3 && round.scores[h.holeNumber]?.fir).length}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Green in Regulation (GIR) */}
|
||||
{settings.trackGIR && (
|
||||
<tr className="text-stone-300 text-xs">
|
||||
<td className="py-2 px-3 text-left font-sans text-stone-400">GIR</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
if (!s || !s.gir) return <td key={h.holeNumber} className="text-stone-600">—</td>;
|
||||
const isHit = s.gir === 'hit';
|
||||
return (
|
||||
<td
|
||||
key={h.holeNumber}
|
||||
className={`py-2 px-1 font-semibold ${
|
||||
isHit ? 'text-emerald-400' : 'text-stone-500'
|
||||
}`}
|
||||
>
|
||||
{isHit ? '✓' : '✗'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 text-stone-400">
|
||||
{holes.filter((h) => round.scores[h.holeNumber]?.gir === 'hit').length}/
|
||||
{holes.filter((h) => round.scores[h.holeNumber]?.gir).length}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Putts (On Green) */}
|
||||
{settings.trackPutts && (
|
||||
<tr className="text-stone-300 text-xs">
|
||||
<td className="py-2 px-3 text-left font-sans text-stone-400">Putts</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
const p = s?.putts;
|
||||
const color = p === 1 ? 'text-emerald-400 font-bold' : p && p >= 3 ? 'text-rose-400 font-bold' : 'text-stone-300';
|
||||
return (
|
||||
<td key={h.holeNumber} className={`py-2 px-1 ${color}`}>
|
||||
{p !== null && p !== undefined ? p : '—'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 font-bold text-stone-200">{subPutts}</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Foregreen / Fringe Putts (Requested!) */}
|
||||
{settings.trackForegreen && (
|
||||
<tr className="text-stone-300 text-xs bg-teal-950/20">
|
||||
<td className="py-2 px-3 text-left font-sans text-teal-400">Foregreen</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
const fg = s?.foregreenPutts;
|
||||
return (
|
||||
<td
|
||||
key={h.holeNumber}
|
||||
className={`py-2 px-1 ${fg && fg > 0 ? 'text-teal-300 font-bold' : 'text-stone-600'}`}
|
||||
>
|
||||
{fg && fg > 0 ? fg : '·'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 font-bold text-teal-300">{subForegreen}</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Bunker Encounters (Fairway vs Greenside) */}
|
||||
{settings.trackBunkers && (
|
||||
<tr className="text-stone-300 text-xs">
|
||||
<td className="py-2 px-3 text-left font-sans text-stone-400">Bunkers</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
const b = s?.bunker;
|
||||
if (!b || !b.hit) return <td key={h.holeNumber} className="text-stone-600">·</td>;
|
||||
let label = 'Sand';
|
||||
if (b.fairwayBunker && b.greensideBunker) label = 'Both';
|
||||
else if (b.fairwayBunker) label = 'FW';
|
||||
else if (b.greensideBunker) label = 'GS';
|
||||
return (
|
||||
<td key={h.holeNumber} className="py-2 px-1 text-amber-300 font-semibold text-[10px]">
|
||||
{label}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 text-stone-400">
|
||||
{holes.filter((h) => round.scores[h.holeNumber]?.bunker?.hit).length}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Penalties (Water, OB, Lost Ball) */}
|
||||
{settings.trackPenalties && (
|
||||
<tr className="text-stone-300 text-xs">
|
||||
<td className="py-2 px-3 text-left font-sans text-stone-400">Penalties</td>
|
||||
{holes.map((h) => {
|
||||
const s = round.scores[h.holeNumber];
|
||||
const pens = s?.penalties || [];
|
||||
const strokeTotal = pens.reduce((acc, p) => acc + p.strokes, 0);
|
||||
if (strokeTotal === 0) return <td key={h.holeNumber} className="text-stone-600">·</td>;
|
||||
|
||||
// Show type abbreviation: W=Water, OB=Out of Bounds, L=Lost, U=Unplayable
|
||||
const types = pens.map((p) => {
|
||||
if (p.type === 'water') return '💧';
|
||||
if (p.type === 'ob') return 'OB';
|
||||
if (p.type === 'lost_ball') return 'Lost';
|
||||
return 'Pen';
|
||||
}).join('/');
|
||||
|
||||
return (
|
||||
<td key={h.holeNumber} className="py-2 px-1 text-rose-400 font-bold text-[10px]">
|
||||
{types}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="py-2 px-2 bg-stone-800/40 font-bold text-rose-400">+{subPens}</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16">
|
||||
{/* Header Banner & Metadata with Course Backdrop */}
|
||||
<div className="relative border border-stone-800 rounded-3xl p-6 shadow-xl overflow-hidden flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
{/* Course Background Image with Scrim */}
|
||||
<div className="absolute inset-0 z-0">
|
||||
<img
|
||||
src="/src/assets/images/golf_course_aerial_1791586533175.jpg"
|
||||
alt={course.name}
|
||||
referrerPolicy="no-referrer"
|
||||
className="w-full h-full object-cover object-center opacity-30"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-stone-950 via-stone-950/85 to-stone-950/60" />
|
||||
</div>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-emerald-400 uppercase tracking-wider">
|
||||
<span>Official Scorecard</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span>{round.date}</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span>Handicap: {round.playerHandicap}</span>
|
||||
</div>
|
||||
<h2 className="text-2xl sm:text-3xl font-extrabold text-white mt-1">
|
||||
{course.name}
|
||||
</h2>
|
||||
<div className="text-xs text-stone-300 mt-1">
|
||||
{course.tee} · Par {totalPar} · {totalYards} {settings.distanceUnit}
|
||||
</div>
|
||||
|
||||
{/* Weather Conditions Badge */}
|
||||
{round.weatherData ? (
|
||||
<div className="flex flex-wrap items-center gap-2 mt-2.5 text-xs text-stone-300 bg-stone-900/80 border border-stone-800/90 rounded-xl px-3 py-1.5 w-fit shadow-inner">
|
||||
<span className="font-bold text-amber-300 flex items-center gap-1">
|
||||
🌡️ {round.weatherData.temperature}°C
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-cyan-300 font-semibold flex items-center gap-1">
|
||||
💨 {round.weatherData.windSpeed} km/h {round.weatherData.windDirectionCompass}
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-blue-300 font-semibold flex items-center gap-1">
|
||||
🌧️ {round.weatherData.precipitationProbability}% rain
|
||||
</span>
|
||||
<span className="text-stone-600">·</span>
|
||||
<span className="text-stone-400">{round.weatherData.conditionText}</span>
|
||||
</div>
|
||||
) : round.weather ? (
|
||||
<div className="mt-2 text-xs text-stone-400 flex items-center gap-1.5">
|
||||
<span>⛅ {round.weather}</span>
|
||||
{round.wind && <span>· {round.wind} wind</span>}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Big Score Summary & Actions */}
|
||||
<div className="relative z-10 flex items-center gap-3">
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wider text-stone-400 font-semibold">
|
||||
Total Score
|
||||
</div>
|
||||
<div className="flex items-baseline gap-2 font-mono">
|
||||
<span className="text-3xl font-extrabold text-white">
|
||||
{totalScore > 0 ? totalScore : '—'}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm font-bold px-2 py-0.5 rounded ${
|
||||
stats.toPar < 0
|
||||
? 'bg-emerald-500/20 text-emerald-400'
|
||||
: stats.toPar > 0
|
||||
? 'bg-rose-500/20 text-rose-400'
|
||||
: 'bg-stone-800 text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{stats.toParDisplay}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<button
|
||||
onClick={handleShareSummary}
|
||||
className="p-2.5 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-200 transition-colors flex items-center gap-1.5 text-xs font-semibold"
|
||||
title="Copy score summary"
|
||||
>
|
||||
{copied ? <Check className="w-4 h-4 text-emerald-400" /> : <Share2 className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{copied ? 'Copied' : 'Share'}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleDownloadCSV}
|
||||
className="p-2.5 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-200 transition-colors flex items-center gap-1.5 text-xs font-semibold"
|
||||
title="Download CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Front 9 Scorecard Table */}
|
||||
{renderNineTable(
|
||||
'Front 9',
|
||||
frontHoles,
|
||||
frontScore,
|
||||
frontPar,
|
||||
frontYards,
|
||||
frontPutts,
|
||||
frontForegreen,
|
||||
frontPenalties
|
||||
)}
|
||||
|
||||
{/* Back 9 Scorecard Table */}
|
||||
{renderNineTable(
|
||||
'Back 9',
|
||||
backHoles,
|
||||
backScore,
|
||||
backPar,
|
||||
backYards,
|
||||
backPutts,
|
||||
backForegreen,
|
||||
backPenalties
|
||||
)}
|
||||
|
||||
{/* 18-Hole Combined Summary Strip */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-2xl p-5 shadow-sm">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-4 text-center">
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">Total Gross</div>
|
||||
<div className="text-2xl font-extrabold text-white font-mono mt-0.5">
|
||||
{totalScore > 0 ? totalScore : '—'}
|
||||
</div>
|
||||
<div className="text-xs font-mono text-stone-400">Par {totalPar}</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">To Par</div>
|
||||
<div
|
||||
className={`text-2xl font-extrabold font-mono mt-0.5 ${
|
||||
stats.toPar < 0 ? 'text-emerald-400' : stats.toPar > 0 ? 'text-rose-400' : 'text-stone-300'
|
||||
}`}
|
||||
>
|
||||
{stats.toParDisplay}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">18 Holes</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">FIR Hit</div>
|
||||
<div className="text-2xl font-extrabold text-white font-mono mt-0.5">
|
||||
{stats.firPercentage}%
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">{stats.firHit}/{stats.firEligible} Fairways</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">GIR Hit</div>
|
||||
<div className="text-2xl font-extrabold text-white font-mono mt-0.5">
|
||||
{stats.girPercentage}%
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">{stats.girHit}/{stats.girEligible} Greens</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">Total Putts</div>
|
||||
<div className="text-2xl font-extrabold text-white font-mono mt-0.5">
|
||||
{totalPutts}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">
|
||||
{stats.avgPuttsPerHole} per hole
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-stone-800/40 p-3 rounded-xl">
|
||||
<div className="text-[11px] text-stone-400 uppercase font-semibold">Foregreen Putts</div>
|
||||
<div className="text-2xl font-extrabold text-teal-400 font-mono mt-0.5">
|
||||
{totalForegreen}
|
||||
</div>
|
||||
<div className="text-xs text-stone-400">{stats.holesWithForegreenPutt} holes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend & Symbology Guide */}
|
||||
<div className="bg-stone-900/60 border border-stone-800/80 rounded-xl p-3.5 text-xs text-stone-400 flex flex-wrap items-center justify-center gap-4 sm:gap-6">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-5 h-5 rounded-full border-2 border-amber-400 bg-amber-500/20 text-amber-300 font-bold inline-flex items-center justify-center text-[10px]">
|
||||
-2
|
||||
</span>
|
||||
<span>Eagle or Better</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-5 h-5 rounded-full border border-emerald-400 bg-emerald-500/20 text-emerald-300 font-bold inline-flex items-center justify-center text-[10px]">
|
||||
-1
|
||||
</span>
|
||||
<span>Birdie</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-stone-200">E</span>
|
||||
<span>Par</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-5 h-5 border border-stone-500 bg-stone-800 text-stone-200 font-semibold rounded-sm inline-flex items-center justify-center text-[10px]">
|
||||
+1
|
||||
</span>
|
||||
<span>Bogey</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="w-5 h-5 border-2 border-rose-500/80 bg-rose-950/40 text-rose-300 font-bold rounded-sm inline-flex items-center justify-center text-[10px]">
|
||||
+2
|
||||
</span>
|
||||
<span>Double Bogey+</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-stone-500">
|
||||
<span>Click any cell to edit that hole</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,563 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Compass,
|
||||
Crosshair,
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
Sliders,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react';
|
||||
import { PresetProfile, TrackingSettings } from '../types/golf';
|
||||
import { SETTING_PRESETS, DEFAULT_TRACKING_SETTINGS } from '../data/courses';
|
||||
|
||||
interface SettingsProps {
|
||||
settings: TrackingSettings;
|
||||
onUpdateSettings: (newSettings: TrackingSettings) => void;
|
||||
}
|
||||
|
||||
export const Settings: React.FC<SettingsProps> = ({
|
||||
settings,
|
||||
onUpdateSettings,
|
||||
}) => {
|
||||
const toggleSetting = (key: keyof TrackingSettings) => {
|
||||
onUpdateSettings({
|
||||
...settings,
|
||||
[key]: !settings[key],
|
||||
});
|
||||
};
|
||||
|
||||
const applyPreset = (preset: PresetProfile) => {
|
||||
if (preset === 'tour') {
|
||||
onUpdateSettings(SETTING_PRESETS.tour);
|
||||
} else if (preset === 'standard') {
|
||||
onUpdateSettings(SETTING_PRESETS.standard);
|
||||
} else if (preset === 'minimal') {
|
||||
onUpdateSettings(SETTING_PRESETS.minimal);
|
||||
}
|
||||
};
|
||||
|
||||
// Determine current active preset if matching
|
||||
const isTour =
|
||||
settings.trackFIR &&
|
||||
settings.trackGIRDirection &&
|
||||
settings.trackForegreen &&
|
||||
settings.trackFirstPuttDistance &&
|
||||
settings.trackDriveDistance;
|
||||
const isMinimal =
|
||||
!settings.trackFIR &&
|
||||
!settings.trackGIR &&
|
||||
!settings.trackBunkers &&
|
||||
!settings.trackForegreen;
|
||||
const isStandard = !isTour && !isMinimal && settings.trackFIR && settings.trackBunkers;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-4 sm:py-6 space-y-6 pb-28 md:pb-16">
|
||||
{/* Header */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-6 shadow-lg">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-bold text-emerald-400 uppercase tracking-wider">
|
||||
<Sliders className="w-4 h-4" />
|
||||
<span>Custom Stat Tracking Settings</span>
|
||||
</div>
|
||||
<h2 className="text-2xl font-extrabold text-white mt-1">
|
||||
Configure Tracked Stats
|
||||
</h2>
|
||||
<p className="text-xs text-stone-400 mt-1 max-w-xl">
|
||||
Turn on or off individual stat categories to match your playing style. The Hole Entry screen and Scorecard dynamically adapt to display only your active metrics.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => onUpdateSettings(DEFAULT_TRACKING_SETTINGS)}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-xl bg-stone-800 hover:bg-stone-700 text-stone-300 hover:text-white text-xs font-semibold transition-colors self-start sm:self-auto"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
<span>Reset Defaults</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Quick Presets Bar */}
|
||||
<div className="mt-6 pt-5 border-t border-stone-800">
|
||||
<div className="text-xs font-semibold text-stone-400 uppercase tracking-wider mb-2.5">
|
||||
Quick Setup Presets:
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2.5">
|
||||
<button
|
||||
onClick={() => applyPreset('tour')}
|
||||
className={`p-3 rounded-2xl text-left border transition-all ${
|
||||
isTour
|
||||
? 'bg-emerald-950/40 border-emerald-500/80 text-white shadow-md'
|
||||
: 'bg-stone-850 border-stone-800 text-stone-300 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm">Tour Pro Analytics</span>
|
||||
{isTour && <CheckCircle2 className="w-4 h-4 text-emerald-400" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1 leading-snug">
|
||||
Every stat enabled: FIR/GIR miss directions, foregreen, fairway & greenside bunkers, penalties, clubs, and distances.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => applyPreset('standard')}
|
||||
className={`p-3 rounded-2xl text-left border transition-all ${
|
||||
isStandard
|
||||
? 'bg-emerald-950/40 border-emerald-500/80 text-white shadow-md'
|
||||
: 'bg-stone-850 border-stone-800 text-stone-300 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm">Standard Amateur</span>
|
||||
{isStandard && <CheckCircle2 className="w-4 h-4 text-emerald-400" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1 leading-snug">
|
||||
Balanced: FIR direction, GIR, putts, foregreen fringe putts, bunker types, and penalty types.
|
||||
</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => applyPreset('minimal')}
|
||||
className={`p-3 rounded-2xl text-left border transition-all ${
|
||||
isMinimal
|
||||
? 'bg-emerald-950/40 border-emerald-500/80 text-white shadow-md'
|
||||
: 'bg-stone-850 border-stone-800 text-stone-300 hover:bg-stone-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-bold text-sm">Minimalist / Fast Play</span>
|
||||
{isMinimal && <CheckCircle2 className="w-4 h-4 text-emerald-400" />}
|
||||
</div>
|
||||
<p className="text-[11px] text-stone-400 mt-1 leading-snug">
|
||||
Rapid scoring: Gross strokes, official putts, and basic penalties only. Zero clutter.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Grouped Settings Switchboard */}
|
||||
<div className="space-y-4">
|
||||
{/* 1. Fairway & Tee Shot Tracking */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<Compass className="w-4 h-4 text-emerald-400" />
|
||||
<span>Fairway in Regulation (FIR)</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Fairway Hit (FIR)</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Record fairway in regulation on Par 4 and Par 5 holes.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackFIR')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackFIR ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.trackFIR && (
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 pl-4 border-l-2 border-stone-800">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Miss Direction</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Record whether fairway was missed Left ⬅, Right ➡, or Short ⬇.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackFIRDirection')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackFIRDirection ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Green in Regulation & Approach */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<Crosshair className="w-4 h-4 text-emerald-400" />
|
||||
<span>Green in Regulation (GIR)</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Green in Regulation</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Track reaching the putting surface in (Par - 2) strokes or fewer.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackGIR')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackGIR ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.trackGIR && (
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 pl-4 border-l-2 border-stone-800">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Missed Green Direction</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Log approach misses: Short, Long, Left, or Right.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackGIRDirection')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackGIRDirection ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Putting & Foregreen (Fringe) Putts */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<span className="w-3 h-3 rounded-full bg-teal-400 inline-block"></span>
|
||||
<span>Putting & Foregreen (Fringe Putts)</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Official Putts (On Green)</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Count strokes taken on the putting green surface.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackPutts')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackPutts ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Foregreen / Fringe Putts (Requested!) */}
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 bg-teal-950/20 -mx-5 px-5">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-teal-300 flex items-center gap-1.5">
|
||||
<span>Track Foregreen / Off-Green (Fringe) Putts</span>
|
||||
<span className="text-[10px] bg-teal-900/60 text-teal-300 px-1.5 py-0.5 rounded font-mono">
|
||||
Special Feature
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Track putter strokes taken from collar or apron, keeping official green putts accurate while analyzing fringe recovery.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackForegreen')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackForegreen ? 'bg-teal-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track First Putt Distance</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Input estimated distance of initial putt in {settings.puttDistanceUnit}.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackFirstPuttDistance')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackFirstPuttDistance ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Bunker Hits (Fairway vs Greenside Bunkers) */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<span className="text-base">🏖️</span>
|
||||
<span>Bunkers & Sand Saves</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Bunker Hits</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Track whether sand traps were hit on each hole.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackBunkers')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackBunkers ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.trackBunkers && (
|
||||
<>
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 pl-4 border-l-2 border-stone-800">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">
|
||||
Distinguish Fairway vs Greenside Bunkers
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Log specifically whether you found a fairway bunker, greenside bunker, or both.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackBunkerTypes')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackBunkerTypes ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 pl-4 border-l-2 border-stone-800">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Sand Saves</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Record up-and-down par saves made from greenside sand.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackSandSaves')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackSandSaves ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Penalty Strokes & Specific Penalty Types */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-400" />
|
||||
<span>Penalties & Hazards</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Penalty Strokes</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Count penalty strokes incurred on each hole.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackPenalties')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackPenalties ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{settings.trackPenalties && (
|
||||
<div className="py-2.5 flex items-center justify-between gap-4 pl-4 border-l-2 border-stone-800">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">
|
||||
Log Specific Penalty Types (Water, OB, Lost Ball)
|
||||
</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Categorize penalties into Water/Hazard 💧, Out of Bounds ⚪, Lost Ball 🔍, or Unplayable ⚠️.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackPenaltyTypes')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackPenaltyTypes ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. Scrambling, Clubs & Hole Notes */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
<span>🏌️♂️</span>
|
||||
<span>Scramble, Club & Notes</span>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-stone-800/60">
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Up & Down (Scramble)</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Track par saves made after missing the green in regulation.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackUpAndDown')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackUpAndDown ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Tee Shot Club</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Select club used off the tee (Driver, 3W, Iron, etc.).
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackDriveClub')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackDriveClub ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="py-2.5 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-stone-200">Track Hole Notes</div>
|
||||
<div className="text-[11px] text-stone-400">
|
||||
Enable custom observations and lie notes per hole.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleSetting('trackHoleNotes')}
|
||||
className={`w-12 h-6 flex items-center rounded-full p-1 transition-colors ${
|
||||
settings.trackHoleNotes ? 'bg-emerald-500 justify-end' : 'bg-stone-700 justify-start'
|
||||
}`}
|
||||
>
|
||||
<div className="w-4 h-4 rounded-full bg-white shadow-md" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. Units of Measurement */}
|
||||
<div className="bg-stone-900 border border-stone-800 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<div className="text-sm font-bold text-white border-b border-stone-800 pb-2.5">
|
||||
Measurement Units
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1.5">
|
||||
Course Yardage Unit:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateSettings({ ...settings, distanceUnit: 'yards' })}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-bold transition-colors ${
|
||||
settings.distanceUnit === 'yards'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Yards
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateSettings({ ...settings, distanceUnit: 'meters' })}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-bold transition-colors ${
|
||||
settings.distanceUnit === 'meters'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Meters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-xs font-semibold text-stone-300 block mb-1.5">
|
||||
Putt Distance Unit:
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateSettings({ ...settings, puttDistanceUnit: 'feet' })}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-bold transition-colors ${
|
||||
settings.puttDistanceUnit === 'feet'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Feet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onUpdateSettings({ ...settings, puttDistanceUnit: 'meters' })}
|
||||
className={`py-2 px-3 rounded-xl text-xs font-bold transition-colors ${
|
||||
settings.puttDistanceUnit === 'meters'
|
||||
? 'bg-emerald-600 text-white'
|
||||
: 'bg-stone-800 text-stone-400 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
Meters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,183 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
|
||||
import { User, AuthProvider } from '../types/auth';
|
||||
import { TrackingSettings } from '../types/golf';
|
||||
import { authApi, getStoredToken, setStoredToken } from '../services/api';
|
||||
|
||||
interface AuthContextValue {
|
||||
user: User | null;
|
||||
token: string | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (identifier: string, pass: string) => Promise<TrackingSettings | undefined>;
|
||||
register: (data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
handicapIndex?: number;
|
||||
homeClub?: string;
|
||||
isPublic?: boolean;
|
||||
}) => Promise<TrackingSettings | undefined>;
|
||||
loginWithSSO: (provider: AuthProvider, customData?: {
|
||||
email?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
authentikServerUrl?: string;
|
||||
}) => Promise<TrackingSettings | undefined>;
|
||||
logout: () => void;
|
||||
updateProfile: (updates: Partial<User>) => Promise<User>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||
|
||||
export const AuthProviderComponent: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [token, setToken] = useState<string | null>(getStoredToken());
|
||||
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const clearError = () => setError(null);
|
||||
|
||||
// Load session on startup
|
||||
useEffect(() => {
|
||||
const initializeAuth = async () => {
|
||||
const stored = getStoredToken();
|
||||
if (!stored) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { user: fetchedUser } = await authApi.getMe();
|
||||
setUser(fetchedUser);
|
||||
setToken(stored);
|
||||
} catch (err) {
|
||||
console.warn('Stored session invalid or expired:', err);
|
||||
setStoredToken(null);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initializeAuth();
|
||||
}, []);
|
||||
|
||||
const login = useCallback(async (identifier: string, pass: string) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authApi.login(identifier, pass);
|
||||
setStoredToken(res.token);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
return res.settings;
|
||||
} catch (err: any) {
|
||||
const msg = err.message || 'Login failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
handicapIndex?: number;
|
||||
homeClub?: string;
|
||||
isPublic?: boolean;
|
||||
}) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authApi.register(data);
|
||||
setStoredToken(res.token);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
return res.settings;
|
||||
} catch (err: any) {
|
||||
const msg = err.message || 'Registration failed';
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loginWithSSO = useCallback(async (provider: AuthProvider, customData?: {
|
||||
email?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
authentikServerUrl?: string;
|
||||
}) => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await authApi.ssoLogin({
|
||||
provider,
|
||||
...customData,
|
||||
});
|
||||
setStoredToken(res.token);
|
||||
setToken(res.token);
|
||||
setUser(res.user);
|
||||
return res.settings;
|
||||
} catch (err: any) {
|
||||
const msg = err.message || `${provider} authentication failed`;
|
||||
setError(msg);
|
||||
throw err;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const logout = useCallback(() => {
|
||||
setStoredToken(null);
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const updateProfile = useCallback(async (updates: Partial<User>) => {
|
||||
try {
|
||||
const { user: updated } = await authApi.updateProfile(updates);
|
||||
setUser(updated);
|
||||
return updated;
|
||||
} catch (err: any) {
|
||||
const msg = err.message || 'Failed to update profile';
|
||||
setError(msg);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider
|
||||
value={{
|
||||
user,
|
||||
token,
|
||||
isLoading,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
loginWithSSO,
|
||||
logout,
|
||||
updateProfile,
|
||||
clearError,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProviderComponent');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
@@ -0,0 +1,224 @@
|
||||
import { Course, TrackingSettings } from '../types/golf';
|
||||
import { generateHoleCoordinates } from '../utils/geoUtils';
|
||||
|
||||
// Default settings with METRIC units as requested!
|
||||
export const DEFAULT_TRACKING_SETTINGS: TrackingSettings = {
|
||||
trackFIR: true,
|
||||
trackFIRDirection: true,
|
||||
trackGIR: true,
|
||||
trackGIRDirection: true,
|
||||
trackPutts: true,
|
||||
trackForegreen: true, // Specifically requested: off-green (foregreen) putts
|
||||
trackFirstPuttDistance: false,
|
||||
trackPenalties: true,
|
||||
trackPenaltyTypes: true, // Water, OB, Lost Ball
|
||||
trackBunkers: true,
|
||||
trackBunkerTypes: true, // Fairway vs Greenside distinction
|
||||
trackSandSaves: true,
|
||||
trackUpAndDown: true,
|
||||
trackDriveClub: true,
|
||||
trackDriveDistance: false,
|
||||
trackHoleNotes: true,
|
||||
distanceUnit: 'meters', // METRIC DEFAULT
|
||||
puttDistanceUnit: 'meters', // METRIC DEFAULT
|
||||
};
|
||||
|
||||
export const SETTING_PRESETS: Record<'tour' | 'standard' | 'minimal', TrackingSettings> = {
|
||||
tour: {
|
||||
trackFIR: true,
|
||||
trackFIRDirection: true,
|
||||
trackGIR: true,
|
||||
trackGIRDirection: true,
|
||||
trackPutts: true,
|
||||
trackForegreen: true,
|
||||
trackFirstPuttDistance: true,
|
||||
trackPenalties: true,
|
||||
trackPenaltyTypes: true,
|
||||
trackBunkers: true,
|
||||
trackBunkerTypes: true,
|
||||
trackSandSaves: true,
|
||||
trackUpAndDown: true,
|
||||
trackDriveClub: true,
|
||||
trackDriveDistance: true,
|
||||
trackHoleNotes: true,
|
||||
distanceUnit: 'meters',
|
||||
puttDistanceUnit: 'meters',
|
||||
},
|
||||
standard: {
|
||||
trackFIR: true,
|
||||
trackFIRDirection: true,
|
||||
trackGIR: true,
|
||||
trackGIRDirection: false,
|
||||
trackPutts: true,
|
||||
trackForegreen: true,
|
||||
trackFirstPuttDistance: false,
|
||||
trackPenalties: true,
|
||||
trackPenaltyTypes: true,
|
||||
trackBunkers: true,
|
||||
trackBunkerTypes: true,
|
||||
trackSandSaves: true,
|
||||
trackUpAndDown: true,
|
||||
trackDriveClub: false,
|
||||
trackDriveDistance: false,
|
||||
trackHoleNotes: false,
|
||||
distanceUnit: 'meters',
|
||||
puttDistanceUnit: 'meters',
|
||||
},
|
||||
minimal: {
|
||||
trackFIR: false,
|
||||
trackFIRDirection: false,
|
||||
trackGIR: false,
|
||||
trackGIRDirection: false,
|
||||
trackPutts: true,
|
||||
trackForegreen: false,
|
||||
trackFirstPuttDistance: false,
|
||||
trackPenalties: true,
|
||||
trackPenaltyTypes: false,
|
||||
trackBunkers: false,
|
||||
trackBunkerTypes: false,
|
||||
trackSandSaves: false,
|
||||
trackUpAndDown: false,
|
||||
trackDriveClub: false,
|
||||
trackDriveDistance: false,
|
||||
trackHoleNotes: false,
|
||||
distanceUnit: 'meters',
|
||||
puttDistanceUnit: 'meters',
|
||||
},
|
||||
};
|
||||
|
||||
// Pebble Beach real course coordinates
|
||||
const PEBBLE_CENTER = { lat: 36.5685, lng: -121.949 };
|
||||
const PEBBLE_BEARINGS = [210, 185, 230, 245, 195, 220, 260, 205, 175, 190, 215, 275, 235, 180, 210, 225, 280, 20];
|
||||
|
||||
// Helper to build holes with authentic GPS coordinates
|
||||
function buildCourseHolesWithCoordinates(
|
||||
centerLat: number,
|
||||
centerLng: number,
|
||||
baseBearings: number[],
|
||||
holeData: Array<{ holeNumber: number; par: number; yardage: number; handicapIndex: number }>
|
||||
) {
|
||||
let currentTee = { lat: centerLat, lng: centerLng };
|
||||
|
||||
return holeData.map((h, i) => {
|
||||
const bearing = baseBearings[i % baseBearings.length];
|
||||
// Convert yardage to meters for physical geography calculation
|
||||
const metersDistance = Math.round(h.yardage * 0.9144);
|
||||
const coords = generateHoleCoordinates(currentTee, metersDistance, bearing);
|
||||
|
||||
// Next hole tee starts near previous green
|
||||
currentTee = {
|
||||
lat: coords.greenBack.lat + 0.0003 * Math.cos((bearing * Math.PI) / 180),
|
||||
lng: coords.greenBack.lng + 0.0003 * Math.sin((bearing * Math.PI) / 180),
|
||||
};
|
||||
|
||||
return {
|
||||
...h,
|
||||
coordinates: coords,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export const DEFAULT_COURSES: Course[] = [
|
||||
{
|
||||
id: 'pebble-cove',
|
||||
name: 'Pebble Cove Ocean Links',
|
||||
location: 'Monterey Peninsula, CA',
|
||||
tee: 'Tournament Blue',
|
||||
parTotal: 72,
|
||||
rating: 74.3,
|
||||
slope: 142,
|
||||
holes: buildCourseHolesWithCoordinates(
|
||||
PEBBLE_CENTER.lat,
|
||||
PEBBLE_CENTER.lng,
|
||||
PEBBLE_BEARINGS,
|
||||
[
|
||||
{ holeNumber: 1, par: 4, yardage: 347, handicapIndex: 8 },
|
||||
{ holeNumber: 2, par: 5, yardage: 459, handicapIndex: 10 },
|
||||
{ holeNumber: 3, par: 4, yardage: 357, handicapIndex: 12 },
|
||||
{ holeNumber: 4, par: 4, yardage: 303, handicapIndex: 16 },
|
||||
{ holeNumber: 5, par: 3, yardage: 172, handicapIndex: 14 },
|
||||
{ holeNumber: 6, par: 5, yardage: 469, handicapIndex: 2 },
|
||||
{ holeNumber: 7, par: 3, yardage: 97, handicapIndex: 18 },
|
||||
{ holeNumber: 8, par: 4, yardage: 382, handicapIndex: 6 },
|
||||
{ holeNumber: 9, par: 4, yardage: 426, handicapIndex: 4 },
|
||||
{ holeNumber: 10, par: 4, yardage: 408, handicapIndex: 7 },
|
||||
{ holeNumber: 11, par: 4, yardage: 341, handicapIndex: 5 },
|
||||
{ holeNumber: 12, par: 3, yardage: 185, handicapIndex: 17 },
|
||||
{ holeNumber: 13, par: 4, yardage: 365, handicapIndex: 9 },
|
||||
{ holeNumber: 14, par: 5, yardage: 517, handicapIndex: 1 },
|
||||
{ holeNumber: 15, par: 4, yardage: 363, handicapIndex: 13 },
|
||||
{ holeNumber: 16, par: 4, yardage: 367, handicapIndex: 11 },
|
||||
{ holeNumber: 17, par: 3, yardage: 163, handicapIndex: 15 },
|
||||
{ holeNumber: 18, par: 5, yardage: 497, handicapIndex: 3 },
|
||||
]
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'augusta-pines',
|
||||
name: 'Pine Valley National',
|
||||
location: 'Clementon, NJ',
|
||||
tee: 'Championship Black',
|
||||
parTotal: 70,
|
||||
rating: 75.1,
|
||||
slope: 155,
|
||||
holes: buildCourseHolesWithCoordinates(
|
||||
39.7915,
|
||||
-74.9723,
|
||||
[45, 90, 135, 180, 225, 270, 315, 0, 45, 90, 135, 180, 225, 270, 315, 0, 90, 180],
|
||||
[
|
||||
{ holeNumber: 1, par: 4, yardage: 385, handicapIndex: 7 },
|
||||
{ holeNumber: 2, par: 4, yardage: 336, handicapIndex: 11 },
|
||||
{ holeNumber: 3, par: 3, yardage: 181, handicapIndex: 15 },
|
||||
{ holeNumber: 4, par: 5, yardage: 551, handicapIndex: 1 },
|
||||
{ holeNumber: 5, par: 3, yardage: 218, handicapIndex: 3 },
|
||||
{ holeNumber: 6, par: 4, yardage: 360, handicapIndex: 9 },
|
||||
{ holeNumber: 7, par: 5, yardage: 535, handicapIndex: 5 },
|
||||
{ holeNumber: 8, par: 4, yardage: 298, handicapIndex: 17 },
|
||||
{ holeNumber: 9, par: 4, yardage: 386, handicapIndex: 13 },
|
||||
{ holeNumber: 10, par: 3, yardage: 147, handicapIndex: 18 },
|
||||
{ holeNumber: 11, par: 4, yardage: 365, handicapIndex: 8 },
|
||||
{ holeNumber: 12, par: 4, yardage: 313, handicapIndex: 14 },
|
||||
{ holeNumber: 13, par: 4, yardage: 444, handicapIndex: 2 },
|
||||
{ holeNumber: 14, par: 3, yardage: 201, handicapIndex: 12 },
|
||||
{ holeNumber: 15, par: 5, yardage: 557, handicapIndex: 4 },
|
||||
{ holeNumber: 16, par: 4, yardage: 434, handicapIndex: 6 },
|
||||
{ holeNumber: 17, par: 4, yardage: 315, handicapIndex: 16 },
|
||||
{ holeNumber: 18, par: 4, yardage: 442, handicapIndex: 10 },
|
||||
]
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'st-andrews-links',
|
||||
name: 'Old Course St. Andrews Links',
|
||||
location: 'Fife, Scotland',
|
||||
tee: 'Medal White',
|
||||
parTotal: 72,
|
||||
rating: 73.1,
|
||||
slope: 138,
|
||||
holes: buildCourseHolesWithCoordinates(
|
||||
56.3432,
|
||||
-2.8028,
|
||||
[340, 340, 340, 340, 340, 340, 340, 340, 340, 160, 160, 160, 160, 160, 160, 160, 160, 160],
|
||||
[
|
||||
{ holeNumber: 1, par: 4, yardage: 344, handicapIndex: 10 },
|
||||
{ holeNumber: 2, par: 4, yardage: 414, handicapIndex: 6 },
|
||||
{ holeNumber: 3, par: 4, yardage: 363, handicapIndex: 16 },
|
||||
{ holeNumber: 4, par: 4, yardage: 439, handicapIndex: 8 },
|
||||
{ holeNumber: 5, par: 5, yardage: 519, handicapIndex: 2 },
|
||||
{ holeNumber: 6, par: 4, yardage: 377, handicapIndex: 14 },
|
||||
{ holeNumber: 7, par: 4, yardage: 339, handicapIndex: 12 },
|
||||
{ holeNumber: 8, par: 3, yardage: 160, handicapIndex: 18 },
|
||||
{ holeNumber: 9, par: 4, yardage: 322, handicapIndex: 4 },
|
||||
{ holeNumber: 10, par: 4, yardage: 347, handicapIndex: 5 },
|
||||
{ holeNumber: 11, par: 3, yardage: 159, handicapIndex: 7 },
|
||||
{ holeNumber: 12, par: 4, yardage: 318, handicapIndex: 17 },
|
||||
{ holeNumber: 13, par: 4, yardage: 425, handicapIndex: 3 },
|
||||
{ holeNumber: 14, par: 5, yardage: 565, handicapIndex: 1 },
|
||||
{ holeNumber: 15, par: 4, yardage: 416, handicapIndex: 9 },
|
||||
{ holeNumber: 16, par: 4, yardage: 387, handicapIndex: 13 },
|
||||
{ holeNumber: 17, par: 4, yardage: 453, handicapIndex: 11 },
|
||||
{ holeNumber: 18, par: 4, yardage: 326, handicapIndex: 15 },
|
||||
]
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,344 @@
|
||||
import { Round } from '../types/golf';
|
||||
import { DEFAULT_TRACKING_SETTINGS } from './courses';
|
||||
|
||||
export const SAMPLE_ROUND: Round = {
|
||||
id: 'round-sample-pebble-2026',
|
||||
date: '2026-10-09',
|
||||
courseId: 'pebble-cove',
|
||||
courseName: 'Pebble Cove Ocean Links',
|
||||
teeName: 'Tournament Blue',
|
||||
coursePar: 72,
|
||||
holesPlayed: 18,
|
||||
nineType: 'all',
|
||||
isCompleted: true,
|
||||
playerHandicap: 8,
|
||||
weather: '22°C · Partly Sunny (14 km/h wind)',
|
||||
wind: 'Breezy',
|
||||
weatherData: {
|
||||
temperature: 22.0,
|
||||
temperatureF: 71.6,
|
||||
apparentTemperature: 21.4,
|
||||
windSpeed: 14.5,
|
||||
windDirection: 240,
|
||||
windDirectionCompass: 'WSW',
|
||||
windCategory: 'Breezy',
|
||||
precipitationProbability: 5,
|
||||
weatherCode: 2,
|
||||
conditionText: 'Partly Sunny',
|
||||
relativeHumidity: 65,
|
||||
locationLabel: 'Pebble Cove Ocean Links (Monterey Peninsula)',
|
||||
latitude: 36.5688,
|
||||
longitude: -121.9507,
|
||||
fetchedAt: '14:30',
|
||||
source: 'gps',
|
||||
},
|
||||
settingsSnapshot: {
|
||||
...DEFAULT_TRACKING_SETTINGS,
|
||||
trackFirstPuttDistance: true,
|
||||
trackDriveDistance: true,
|
||||
},
|
||||
scores: {
|
||||
// Hole 1: Par 4, 380y. Good start: Par
|
||||
1: {
|
||||
holeNumber: 1,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 22,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 284,
|
||||
notes: 'Crisp drive down center fairway, two-putt par.',
|
||||
},
|
||||
// Hole 2: Par 5, 502y. Birdie!
|
||||
2: {
|
||||
holeNumber: 2,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 6,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 295,
|
||||
notes: 'Approach spun back to 6 feet, rolled in birdie putt!',
|
||||
},
|
||||
// Hole 3: Par 4, 390y. Fairway Bunker encounter!
|
||||
3: {
|
||||
holeNumber: 3,
|
||||
grossScore: 5,
|
||||
fir: 'right',
|
||||
gir: 'miss_short',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 18,
|
||||
penalties: [],
|
||||
bunker: { hit: true, fairwayBunker: true, greensideBunker: false, sandSave: null },
|
||||
upAndDown: false,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 270,
|
||||
notes: 'Drove into right fairway bunker. Laid up short, 2-putt bogey.',
|
||||
},
|
||||
// Hole 4: Par 4, 331y. Foregreen / Fringe Putt demonstration!
|
||||
4: {
|
||||
holeNumber: 4,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'miss_short',
|
||||
putts: 1,
|
||||
foregreenPutts: 1,
|
||||
firstPuttFromFringe: true,
|
||||
firstPuttDistanceFt: 35,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: true,
|
||||
driveClub: '3-Wood',
|
||||
driveDistanceYds: 250,
|
||||
notes: 'Approach landed on foregreen fringe. Putted from fringe to 3ft, tapped in.',
|
||||
},
|
||||
// Hole 5: Par 3, 188y. Greenside Bunker & Sand Save!
|
||||
5: {
|
||||
holeNumber: 5,
|
||||
grossScore: 3,
|
||||
fir: 'na',
|
||||
gir: 'miss_right',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 4,
|
||||
penalties: [],
|
||||
bunker: { hit: true, fairwayBunker: false, greensideBunker: true, sandSave: true },
|
||||
upAndDown: true,
|
||||
driveClub: '5-Iron',
|
||||
notes: 'Plugged in greenside bunker. Blasted to 4 feet and made the clutch sand save par!',
|
||||
},
|
||||
// Hole 6: Par 5, 513y. Par
|
||||
6: {
|
||||
holeNumber: 6,
|
||||
grossScore: 5,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 30,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 290,
|
||||
},
|
||||
// Hole 7: Par 3, 106y. Birdie
|
||||
7: {
|
||||
holeNumber: 7,
|
||||
grossScore: 2,
|
||||
fir: 'na',
|
||||
gir: 'hit',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 8,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Gap Wedge',
|
||||
notes: 'Stuck wedge over coastal breeze to 8ft.',
|
||||
},
|
||||
// Hole 8: Par 4, 418y. Water Hazard penalty!
|
||||
8: {
|
||||
holeNumber: 8,
|
||||
grossScore: 6,
|
||||
fir: 'hit',
|
||||
gir: 'miss_right',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 15,
|
||||
penalties: [
|
||||
{ id: 'pen-1', type: 'water', strokes: 1, shotNumber: 2, notes: 'Ocean cliff hazard on approach' },
|
||||
],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: false,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 282,
|
||||
notes: 'Pushed 4-iron into coastal ocean hazard. Took drop and two-putted for double.',
|
||||
},
|
||||
// Hole 9: Par 4, 466y. Par (Front 9 Total: 37 = +1)
|
||||
9: {
|
||||
holeNumber: 9,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 25,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 288,
|
||||
},
|
||||
// Hole 10: Par 4, 446y. Par
|
||||
10: {
|
||||
holeNumber: 10,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 14,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 279,
|
||||
},
|
||||
// Hole 11: Par 4, 373y. Birdie
|
||||
11: {
|
||||
holeNumber: 11,
|
||||
grossScore: 3,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 11,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 275,
|
||||
notes: 'Drained a curling 11-foot left-to-right putt.',
|
||||
},
|
||||
// Hole 12: Par 3, 202y. Par
|
||||
12: {
|
||||
holeNumber: 12,
|
||||
grossScore: 3,
|
||||
fir: 'na',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 32,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: '4-Iron',
|
||||
},
|
||||
// Hole 13: Par 4, 399y. Lost Ball penalty & Bogey
|
||||
13: {
|
||||
holeNumber: 13,
|
||||
grossScore: 5,
|
||||
fir: 'left',
|
||||
gir: 'miss_left',
|
||||
putts: 1,
|
||||
foregreenPutts: 1,
|
||||
firstPuttFromFringe: true,
|
||||
firstPuttDistanceFt: 18,
|
||||
penalties: [
|
||||
{ id: 'pen-2', type: 'lost_ball', strokes: 1, shotNumber: 1, notes: 'Deep native fescue grass left' },
|
||||
],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: false,
|
||||
driveClub: 'Driver',
|
||||
notes: 'Lost ball in high fescue rough, dropped provisional, putted from fringe.',
|
||||
},
|
||||
// Hole 14: Par 5, 565y. Fairway & Greenside Bunkers (Both)!
|
||||
14: {
|
||||
holeNumber: 14,
|
||||
grossScore: 6,
|
||||
fir: 'right',
|
||||
gir: 'miss_short',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 9,
|
||||
penalties: [],
|
||||
bunker: { hit: true, fairwayBunker: true, greensideBunker: true, sandSave: false },
|
||||
upAndDown: false,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 260,
|
||||
notes: 'Caught fairway bunker off tee, then greenside bunker on third shot.',
|
||||
},
|
||||
// Hole 15: Par 4, 397y. Par
|
||||
15: {
|
||||
holeNumber: 15,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 16,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 285,
|
||||
},
|
||||
// Hole 16: Par 4, 401y. Out of Bounds (OB) demonstration!
|
||||
16: {
|
||||
holeNumber: 16,
|
||||
grossScore: 6,
|
||||
fir: 'right',
|
||||
gir: 'miss_right',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 12,
|
||||
penalties: [
|
||||
{ id: 'pen-3', type: 'ob', strokes: 1, shotNumber: 1, notes: 'White stakes right fence line' },
|
||||
],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: false,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 270,
|
||||
notes: 'Tee shot sliced OB. Re-teed shot 3, salvaged double.',
|
||||
},
|
||||
// Hole 17: Par 3, 178y. Greenside bunker Sand Save!
|
||||
17: {
|
||||
holeNumber: 17,
|
||||
grossScore: 3,
|
||||
fir: 'na',
|
||||
gir: 'miss_left',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 3,
|
||||
penalties: [],
|
||||
bunker: { hit: true, fairwayBunker: false, greensideBunker: true, sandSave: true },
|
||||
upAndDown: true,
|
||||
driveClub: '6-Iron',
|
||||
notes: 'Left greenside pot bunker. Fluffed up to 3 feet and converted sand save!',
|
||||
},
|
||||
// Hole 18: Par 5, 543y. Spectacular Birdie finish!
|
||||
18: {
|
||||
holeNumber: 18,
|
||||
grossScore: 4,
|
||||
fir: 'hit',
|
||||
gir: 'hit',
|
||||
putts: 1,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: 7,
|
||||
penalties: [],
|
||||
bunker: { hit: false, fairwayBunker: false, greensideBunker: false, sandSave: null },
|
||||
upAndDown: null,
|
||||
driveClub: 'Driver',
|
||||
driveDistanceYds: 298,
|
||||
notes: 'Stunning 3-wood to the back fringe, one putt birdie finish in front of clubhouse!',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
font-family: 'Plus Jakarta Sans', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
}
|
||||
|
||||
.font-mono, .tabular-nums {
|
||||
font-family: 'JetBrains Mono', monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbars for compact horizontal scrollers */
|
||||
::-webkit-scrollbar {
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(148, 163, 184, 0.25);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(148, 163, 184, 0.45);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App />);
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { db } from './db';
|
||||
import { User } from '../types/auth';
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'pinpoint_golf_super_secret_jwt_key_2026';
|
||||
|
||||
export interface AuthenticatedRequest extends Request {
|
||||
user?: User;
|
||||
}
|
||||
|
||||
export function generateToken(user: User): string {
|
||||
return jwt.sign(
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: user.username,
|
||||
},
|
||||
JWT_SECRET,
|
||||
{ expiresIn: '30d' }
|
||||
);
|
||||
}
|
||||
|
||||
export function authMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Authentication token missing or invalid' });
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||
const user = db.findUserById(decoded.id);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'User no longer exists' });
|
||||
}
|
||||
req.user = user;
|
||||
next();
|
||||
} catch (err) {
|
||||
return res.status(401).json({ error: 'Invalid or expired session token' });
|
||||
}
|
||||
}
|
||||
|
||||
// Optional auth: attaches user if token is valid, but doesn't block if not
|
||||
export function optionalAuthMiddleware(req: AuthenticatedRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const token = authHeader.split(' ')[1];
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET) as { id: string };
|
||||
const user = db.findUserById(decoded.id);
|
||||
if (user) {
|
||||
req.user = user;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid tokens for optional auth
|
||||
}
|
||||
next();
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { User, PublicGolferStats } from '../types/auth';
|
||||
import { Round, TrackingSettings, Course } from '../types/golf';
|
||||
import { DEFAULT_TRACKING_SETTINGS } from '../data/courses';
|
||||
|
||||
interface DatabaseSchema {
|
||||
users: Array<User & { passwordHash?: string }>;
|
||||
settings: Record<string, TrackingSettings>; // userId -> settings
|
||||
rounds: Record<string, Round[]>; // userId -> rounds
|
||||
customCourses: Record<string, Course[]>; // userId -> courses
|
||||
publicSeedGolfers: PublicGolferStats[];
|
||||
}
|
||||
|
||||
const DATA_DIR = path.resolve(process.cwd(), 'data');
|
||||
const DB_FILE = path.join(DATA_DIR, 'pinpoint_db.json');
|
||||
|
||||
// Ensure data directory exists
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Initial seed public golfers for rich community stats & benchmarking
|
||||
const SEED_PUBLIC_GOLFERS: PublicGolferStats[] = [
|
||||
{
|
||||
id: 'user_seed_viktor',
|
||||
username: 'viktor_h',
|
||||
name: 'Viktor H.',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 0.4,
|
||||
homeClub: 'Oslo Golfklubb',
|
||||
roundsCount: 24,
|
||||
scoringAverage: 71.2,
|
||||
bestGross: 67,
|
||||
firPercentage: 74.5,
|
||||
girPercentage: 76.2,
|
||||
puttsPerRound: 28.4,
|
||||
scramblePercentage: 68.0,
|
||||
sandSavePercentage: 62.5,
|
||||
penaltiesPerRound: 0.3,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'user_seed_marcus',
|
||||
username: 'marcus_t',
|
||||
name: 'Marcus Thorne',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1570295999919-56ceb5ecca61?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 5.8,
|
||||
homeClub: 'Pine Valley GC',
|
||||
roundsCount: 18,
|
||||
scoringAverage: 76.8,
|
||||
bestGross: 73,
|
||||
firPercentage: 66.0,
|
||||
girPercentage: 62.5,
|
||||
puttsPerRound: 30.1,
|
||||
scramblePercentage: 54.0,
|
||||
sandSavePercentage: 47.0,
|
||||
penaltiesPerRound: 0.8,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'user_seed_sarah',
|
||||
username: 'sarah_m',
|
||||
name: 'Sarah Miller',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 11.2,
|
||||
homeClub: 'St Andrews Links Club',
|
||||
roundsCount: 15,
|
||||
scoringAverage: 82.4,
|
||||
bestGross: 78,
|
||||
firPercentage: 58.2,
|
||||
girPercentage: 51.0,
|
||||
puttsPerRound: 31.6,
|
||||
scramblePercentage: 42.0,
|
||||
sandSavePercentage: 36.0,
|
||||
penaltiesPerRound: 1.4,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'user_seed_dave',
|
||||
username: 'dave_k',
|
||||
name: 'Dave Kowalski',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 18.6,
|
||||
homeClub: 'Bandon Dunes Golfers',
|
||||
roundsCount: 12,
|
||||
scoringAverage: 91.5,
|
||||
bestGross: 86,
|
||||
firPercentage: 44.0,
|
||||
girPercentage: 32.5,
|
||||
puttsPerRound: 34.2,
|
||||
scramblePercentage: 28.0,
|
||||
sandSavePercentage: 21.0,
|
||||
penaltiesPerRound: 2.6,
|
||||
isPublic: true,
|
||||
},
|
||||
{
|
||||
id: 'user_seed_elena',
|
||||
username: 'elena_rostova',
|
||||
name: 'Elena Rostova',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 8.9,
|
||||
homeClub: 'Torrey Pines South',
|
||||
roundsCount: 20,
|
||||
scoringAverage: 79.8,
|
||||
bestGross: 75,
|
||||
firPercentage: 62.4,
|
||||
girPercentage: 57.8,
|
||||
puttsPerRound: 31.0,
|
||||
scramblePercentage: 48.0,
|
||||
sandSavePercentage: 40.0,
|
||||
penaltiesPerRound: 1.1,
|
||||
isPublic: true,
|
||||
},
|
||||
];
|
||||
|
||||
class Database {
|
||||
private data: DatabaseSchema;
|
||||
|
||||
constructor() {
|
||||
this.data = this.load();
|
||||
this.seedDefaultUserIfEmpty();
|
||||
}
|
||||
|
||||
private load(): DatabaseSchema {
|
||||
try {
|
||||
if (fs.existsSync(DB_FILE)) {
|
||||
const raw = fs.readFileSync(DB_FILE, 'utf-8');
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load database file, creating fresh store:', e);
|
||||
}
|
||||
return {
|
||||
users: [],
|
||||
settings: {},
|
||||
rounds: {},
|
||||
customCourses: {},
|
||||
publicSeedGolfers: SEED_PUBLIC_GOLFERS,
|
||||
};
|
||||
}
|
||||
|
||||
private save(): void {
|
||||
try {
|
||||
const tempPath = `${DB_FILE}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(this.data, null, 2), 'utf-8');
|
||||
fs.renameSync(tempPath, DB_FILE);
|
||||
} catch (e) {
|
||||
console.error('Error saving database:', e);
|
||||
}
|
||||
}
|
||||
|
||||
private seedDefaultUserIfEmpty() {
|
||||
if (!this.data.users || this.data.users.length === 0) {
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const passwordHash = bcrypt.hashSync('golf1234', salt);
|
||||
const demoUser: User & { passwordHash: string } = {
|
||||
id: 'usr_demo_golfer',
|
||||
email: 'demo@pinpointgolf.com',
|
||||
username: 'demogolfer',
|
||||
name: 'Demo Golfer',
|
||||
avatarUrl: 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&w=120&h=120&q=80',
|
||||
handicapIndex: 12.4,
|
||||
homeClub: 'Pebble Cove Links',
|
||||
isPublic: true,
|
||||
provider: 'local',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
passwordHash,
|
||||
};
|
||||
this.data.users = [demoUser];
|
||||
this.data.settings[demoUser.id] = { ...DEFAULT_TRACKING_SETTINGS };
|
||||
this.save();
|
||||
}
|
||||
|
||||
if (!this.data.publicSeedGolfers || this.data.publicSeedGolfers.length === 0) {
|
||||
this.data.publicSeedGolfers = SEED_PUBLIC_GOLFERS;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
// --- User Operations ---
|
||||
public getUsers(): Array<User & { passwordHash?: string }> {
|
||||
return this.data.users;
|
||||
}
|
||||
|
||||
public findUserById(id: string): User | undefined {
|
||||
const user = this.data.users.find((u) => u.id === id);
|
||||
if (!user) return undefined;
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
public findUserByEmailOrUsername(identifier: string): (User & { passwordHash?: string }) | undefined {
|
||||
const lower = identifier.toLowerCase().trim();
|
||||
return this.data.users.find(
|
||||
(u) => u.email.toLowerCase() === lower || u.username.toLowerCase() === lower
|
||||
);
|
||||
}
|
||||
|
||||
public createUser(userData: User & { passwordHash?: string }): User {
|
||||
this.data.users.push(userData);
|
||||
if (!this.data.settings[userData.id]) {
|
||||
this.data.settings[userData.id] = { ...DEFAULT_TRACKING_SETTINGS };
|
||||
}
|
||||
if (!this.data.rounds[userData.id]) {
|
||||
this.data.rounds[userData.id] = [];
|
||||
}
|
||||
this.save();
|
||||
const { passwordHash: _, ...safeUser } = userData;
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
public updateUser(id: string, updates: Partial<User>): User | undefined {
|
||||
const index = this.data.users.findIndex((u) => u.id === id);
|
||||
if (index === -1) return undefined;
|
||||
|
||||
this.data.users[index] = {
|
||||
...this.data.users[index],
|
||||
...updates,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
this.save();
|
||||
|
||||
const { passwordHash: _, ...safeUser } = this.data.users[index];
|
||||
return safeUser;
|
||||
}
|
||||
|
||||
// --- Settings Operations ---
|
||||
public getSettings(userId: string): TrackingSettings {
|
||||
return this.data.settings[userId] || { ...DEFAULT_TRACKING_SETTINGS };
|
||||
}
|
||||
|
||||
public updateSettings(userId: string, settings: TrackingSettings): TrackingSettings {
|
||||
this.data.settings[userId] = settings;
|
||||
this.save();
|
||||
return settings;
|
||||
}
|
||||
|
||||
// --- Rounds Operations ---
|
||||
public getRounds(userId: string): Round[] {
|
||||
return this.data.rounds[userId] || [];
|
||||
}
|
||||
|
||||
public saveRound(userId: string, round: Round): Round {
|
||||
if (!this.data.rounds[userId]) {
|
||||
this.data.rounds[userId] = [];
|
||||
}
|
||||
const existingIndex = this.data.rounds[userId].findIndex((r) => r.id === round.id);
|
||||
if (existingIndex >= 0) {
|
||||
this.data.rounds[userId][existingIndex] = round;
|
||||
} else {
|
||||
this.data.rounds[userId].unshift(round);
|
||||
}
|
||||
this.save();
|
||||
return round;
|
||||
}
|
||||
|
||||
public syncRoundsBatch(userId: string, rounds: Round[]): Round[] {
|
||||
if (!this.data.rounds[userId]) {
|
||||
this.data.rounds[userId] = [];
|
||||
}
|
||||
for (const r of rounds) {
|
||||
const idx = this.data.rounds[userId].findIndex((existing) => existing.id === r.id);
|
||||
if (idx >= 0) {
|
||||
this.data.rounds[userId][idx] = r;
|
||||
} else {
|
||||
this.data.rounds[userId].push(r);
|
||||
}
|
||||
}
|
||||
this.save();
|
||||
return this.data.rounds[userId];
|
||||
}
|
||||
|
||||
public deleteRound(userId: string, roundId: string): boolean {
|
||||
if (!this.data.rounds[userId]) return false;
|
||||
const initialLen = this.data.rounds[userId].length;
|
||||
this.data.rounds[userId] = this.data.rounds[userId].filter((r) => r.id !== roundId);
|
||||
const changed = this.data.rounds[userId].length !== initialLen;
|
||||
if (changed) this.save();
|
||||
return changed;
|
||||
}
|
||||
|
||||
// --- Custom Courses Operations ---
|
||||
public getCustomCourses(userId: string): Course[] {
|
||||
return this.data.customCourses[userId] || [];
|
||||
}
|
||||
|
||||
public saveCustomCourse(userId: string, course: Course): Course {
|
||||
if (!this.data.customCourses[userId]) {
|
||||
this.data.customCourses[userId] = [];
|
||||
}
|
||||
const idx = this.data.customCourses[userId].findIndex((c) => c.id === course.id);
|
||||
if (idx >= 0) {
|
||||
this.data.customCourses[userId][idx] = course;
|
||||
} else {
|
||||
this.data.customCourses[userId].push(course);
|
||||
}
|
||||
this.save();
|
||||
return course;
|
||||
}
|
||||
|
||||
// --- Community & Public Stats Operations ---
|
||||
public getPublicGolfers(): PublicGolferStats[] {
|
||||
const list: PublicGolferStats[] = [...(this.data.publicSeedGolfers || [])];
|
||||
|
||||
// Compute public stats for any real registered users with isPublic == true
|
||||
for (const user of this.data.users) {
|
||||
if (!user.isPublic) continue;
|
||||
const userRounds = this.data.rounds[user.id] || [];
|
||||
if (userRounds.length === 0) continue;
|
||||
|
||||
let totalGross = 0;
|
||||
let bestGross = 999;
|
||||
let totalPutts = 0;
|
||||
let totalPenalties = 0;
|
||||
let totalFIRPossible = 0;
|
||||
let totalFIRHit = 0;
|
||||
let totalGIRPossible = 0;
|
||||
let totalGIRHit = 0;
|
||||
let totalScramblePossible = 0;
|
||||
let totalScrambleMade = 0;
|
||||
let totalSandPossible = 0;
|
||||
let totalSandMade = 0;
|
||||
|
||||
for (const round of userRounds) {
|
||||
const scores = Object.values(round.scores || {}).filter((h) => h && h.grossScore > 0);
|
||||
if (scores.length === 0) continue;
|
||||
const roundGross = scores.reduce((sum, h) => sum + h.grossScore, 0);
|
||||
totalGross += roundGross;
|
||||
if (roundGross < bestGross && scores.length >= 9) {
|
||||
bestGross = roundGross;
|
||||
}
|
||||
|
||||
for (const h of scores) {
|
||||
totalPutts += h.putts || 0;
|
||||
totalPenalties += h.penalties?.reduce((p, item) => p + item.strokes, 0) || 0;
|
||||
|
||||
if (h.fir !== 'na') {
|
||||
totalFIRPossible++;
|
||||
if (h.fir === 'hit') totalFIRHit++;
|
||||
}
|
||||
|
||||
totalGIRPossible++;
|
||||
if (h.gir === 'hit') {
|
||||
totalGIRHit++;
|
||||
} else if (h.upAndDown !== null && h.upAndDown !== undefined) {
|
||||
totalScramblePossible++;
|
||||
if (h.upAndDown) totalScrambleMade++;
|
||||
}
|
||||
|
||||
if (h.bunker?.hit) {
|
||||
totalSandPossible++;
|
||||
if (h.bunker?.sandSave) totalSandMade++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const count = userRounds.length;
|
||||
const scoringAvg = count > 0 ? Number((totalGross / count).toFixed(1)) : 80;
|
||||
const firPct = totalFIRPossible > 0 ? Number(((totalFIRHit / totalFIRPossible) * 100).toFixed(1)) : 60;
|
||||
const girPct = totalGIRPossible > 0 ? Number(((totalGIRHit / totalGIRPossible) * 100).toFixed(1)) : 50;
|
||||
const puttsAvg = count > 0 ? Number((totalPutts / count).toFixed(1)) : 32;
|
||||
const scramblePct = totalScramblePossible > 0 ? Number(((totalScrambleMade / totalScramblePossible) * 100).toFixed(1)) : 40;
|
||||
const sandPct = totalSandPossible > 0 ? Number(((totalSandMade / totalSandPossible) * 100).toFixed(1)) : 35;
|
||||
const pensAvg = count > 0 ? Number((totalPenalties / count).toFixed(1)) : 1.2;
|
||||
|
||||
list.push({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
avatarUrl: user.avatarUrl,
|
||||
handicapIndex: user.handicapIndex,
|
||||
homeClub: user.homeClub,
|
||||
roundsCount: count,
|
||||
scoringAverage: scoringAvg,
|
||||
bestGross: bestGross < 999 ? bestGross : scoringAvg,
|
||||
firPercentage: firPct,
|
||||
girPercentage: girPct,
|
||||
puttsPerRound: puttsAvg,
|
||||
scramblePercentage: scramblePct,
|
||||
sandSavePercentage: sandPct,
|
||||
penaltiesPerRound: pensAvg,
|
||||
isPublic: true,
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
export const db = new Database();
|
||||
@@ -0,0 +1,212 @@
|
||||
import { Router, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { db } from '../db';
|
||||
import { generateToken, authMiddleware, AuthenticatedRequest } from '../authMiddleware';
|
||||
import { User, AuthProvider } from '../../types/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- 1. Local Registration ---
|
||||
router.post('/register', (req, res: Response) => {
|
||||
try {
|
||||
const { email, username, password, name, handicapIndex, homeClub, isPublic } = req.body;
|
||||
|
||||
if (!email || !username || !password) {
|
||||
return res.status(400).json({ error: 'Email, username, and password are required' });
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return res.status(400).json({ error: 'Password must be at least 6 characters' });
|
||||
}
|
||||
|
||||
const existingUser = db.findUserByEmailOrUsername(email) || db.findUserByEmailOrUsername(username);
|
||||
if (existingUser) {
|
||||
return res.status(409).json({ error: 'An account with that email or username already exists' });
|
||||
}
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const passwordHash = bcrypt.hashSync(password, salt);
|
||||
|
||||
const newUser: User & { passwordHash: string } = {
|
||||
id: `usr_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
email: email.toLowerCase().trim(),
|
||||
username: username.toLowerCase().trim(),
|
||||
name: name?.trim() || username.trim(),
|
||||
handicapIndex: typeof handicapIndex === 'number' ? handicapIndex : 15.0,
|
||||
homeClub: homeClub?.trim() || 'Local Links Club',
|
||||
isPublic: isPublic !== false, // default true for community leaderboards
|
||||
provider: 'local',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
passwordHash,
|
||||
};
|
||||
|
||||
const savedUser = db.createUser(newUser);
|
||||
const token = generateToken(savedUser);
|
||||
|
||||
return res.status(201).json({
|
||||
token,
|
||||
user: savedUser,
|
||||
settings: db.getSettings(savedUser.id),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
return res.status(500).json({ error: 'Internal registration failure' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 2. Local Login ---
|
||||
router.post('/login', (req, res: Response) => {
|
||||
try {
|
||||
const { identifier, password } = req.body;
|
||||
|
||||
if (!identifier || !password) {
|
||||
return res.status(400).json({ error: 'Email/Username and password are required' });
|
||||
}
|
||||
|
||||
const userWithHash = db.findUserByEmailOrUsername(identifier);
|
||||
if (!userWithHash || !userWithHash.passwordHash) {
|
||||
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' });
|
||||
}
|
||||
|
||||
const isValid = bcrypt.compareSync(password, userWithHash.passwordHash);
|
||||
if (!isValid) {
|
||||
return res.status(401).json({ error: 'Invalid credentials. Please verify your email and password.' });
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...safeUser } = userWithHash;
|
||||
const token = generateToken(safeUser);
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
user: safeUser,
|
||||
settings: db.getSettings(safeUser.id),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
return res.status(500).json({ error: 'Internal login failure' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 3. Get Current User & Profile ---
|
||||
router.get('/me', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const settings = db.getSettings(req.user.id);
|
||||
return res.json({
|
||||
user: req.user,
|
||||
settings,
|
||||
});
|
||||
});
|
||||
|
||||
// --- 4. Update Profile & Privacy (Public / Private toggle) ---
|
||||
router.put('/profile', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
|
||||
const { name, handicapIndex, homeClub, isPublic, avatarUrl } = req.body;
|
||||
const updates: Partial<User> = {};
|
||||
|
||||
if (typeof name === 'string' && name.trim()) updates.name = name.trim();
|
||||
if (typeof handicapIndex === 'number') updates.handicapIndex = Math.max(-10, Math.min(54, handicapIndex));
|
||||
if (typeof homeClub === 'string') updates.homeClub = homeClub.trim();
|
||||
if (typeof isPublic === 'boolean') updates.isPublic = isPublic;
|
||||
if (typeof avatarUrl === 'string') updates.avatarUrl = avatarUrl;
|
||||
|
||||
const updatedUser = db.updateUser(req.user.id, updates);
|
||||
return res.json({ user: updatedUser });
|
||||
});
|
||||
|
||||
// --- 5. User Settings Sync ---
|
||||
router.get('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
return res.json(db.getSettings(req.user.id));
|
||||
});
|
||||
|
||||
router.put('/settings', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const updated = db.updateSettings(req.user.id, req.body);
|
||||
return res.json(updated);
|
||||
});
|
||||
|
||||
// --- 6. OAuth Providers Discovery ---
|
||||
router.get('/oauth/providers', (_req, res: Response) => {
|
||||
res.json({
|
||||
providers: [
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
icon: 'Google',
|
||||
isConfigured: !!process.env.GOOGLE_CLIENT_ID,
|
||||
authUrlEndpoint: '/api/auth/oauth/google',
|
||||
},
|
||||
{
|
||||
id: 'authentik',
|
||||
name: 'Authentik (OIDC)',
|
||||
icon: 'Shield',
|
||||
isConfigured: !!process.env.AUTHENTIK_ISSUER_URL,
|
||||
issuerUrl: process.env.AUTHENTIK_ISSUER_URL || 'https://authentik.company.local',
|
||||
authUrlEndpoint: '/api/auth/oauth/authentik',
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
icon: 'Github',
|
||||
isConfigured: !!process.env.GITHUB_CLIENT_ID,
|
||||
authUrlEndpoint: '/api/auth/oauth/github',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
// --- 7. OAuth / SSO Login (Google, Authentik, GitHub, Demo SSO) ---
|
||||
router.post('/oauth/sso-login', (req, res: Response) => {
|
||||
try {
|
||||
const { provider, email, name, username, avatarUrl, authentikServerUrl } = req.body;
|
||||
|
||||
if (!provider) {
|
||||
return res.status(400).json({ error: 'Provider is required' });
|
||||
}
|
||||
|
||||
const providerType = provider as AuthProvider;
|
||||
// Generate reliable email & username if from SSO
|
||||
const ssoEmail = email?.toLowerCase().trim() || `${provider}_golfer_${Date.now().toString(36)}@example.com`;
|
||||
const ssoUsername = username?.toLowerCase().trim() || `${provider}_${Math.random().toString(36).substring(2, 7)}`;
|
||||
const ssoName = name?.trim() || `${provider.charAt(0).toUpperCase() + provider.slice(1)} Golfer`;
|
||||
|
||||
// Check if user already exists
|
||||
let user = db.findUserByEmailOrUsername(ssoEmail);
|
||||
|
||||
if (!user) {
|
||||
// Create new user connected via SSO
|
||||
const newUser: User = {
|
||||
id: `usr_${provider}_${Date.now()}_${Math.random().toString(36).substring(2, 7)}`,
|
||||
email: ssoEmail,
|
||||
username: ssoUsername,
|
||||
name: ssoName,
|
||||
avatarUrl: avatarUrl || (provider === 'authentik'
|
||||
? 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&w=120&h=120&q=80'
|
||||
: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?auto=format&fit=crop&w=120&h=120&q=80'),
|
||||
handicapIndex: 12.0,
|
||||
homeClub: authentikServerUrl ? `Authentik SSO (${new URL(authentikServerUrl).hostname})` : 'Global Golf Tour',
|
||||
isPublic: true,
|
||||
provider: providerType,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
user = db.createUser(newUser);
|
||||
}
|
||||
|
||||
const { passwordHash: _, ...safeUser } = user;
|
||||
const token = generateToken(safeUser);
|
||||
|
||||
return res.json({
|
||||
token,
|
||||
user: safeUser,
|
||||
settings: db.getSettings(safeUser.id),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('SSO Login error:', error);
|
||||
return res.status(500).json({ error: 'SSO authentication failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,242 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { db } from '../db';
|
||||
import { optionalAuthMiddleware, AuthenticatedRequest } from '../authMiddleware';
|
||||
import { PublicGolferStats, ComparisonMetric, CommunityComparisonData } from '../../types/auth';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- 1. Get Public Golfers Leaderboard ---
|
||||
router.get('/leaderboard', (_req, res: Response) => {
|
||||
try {
|
||||
const golfers = db.getPublicGolfers();
|
||||
|
||||
// Sort by scoring average ascending (lowest score first)
|
||||
golfers.sort((a, b) => a.scoringAverage - b.scoringAverage);
|
||||
|
||||
res.json({
|
||||
totalCount: golfers.length,
|
||||
golfers,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Leaderboard fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve public leaderboard' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- 2. Head-to-Head Community Benchmark Comparison ---
|
||||
router.get('/compare', optionalAuthMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const publicGolfers = db.getPublicGolfers();
|
||||
|
||||
if (publicGolfers.length === 0) {
|
||||
return res.status(404).json({ error: 'No public statistics available' });
|
||||
}
|
||||
|
||||
// Determine current user stats
|
||||
let userStats: PublicGolferStats;
|
||||
|
||||
if (req.user) {
|
||||
const foundInPublic = publicGolfers.find((g) => g.id === req.user!.id);
|
||||
if (foundInPublic) {
|
||||
userStats = foundInPublic;
|
||||
} else {
|
||||
// Fallback for user with no rounds or private profile
|
||||
userStats = {
|
||||
id: req.user.id,
|
||||
username: req.user.username,
|
||||
name: req.user.name,
|
||||
avatarUrl: req.user.avatarUrl,
|
||||
handicapIndex: req.user.handicapIndex,
|
||||
homeClub: req.user.homeClub,
|
||||
roundsCount: 0,
|
||||
scoringAverage: Math.round(72 + req.user.handicapIndex),
|
||||
bestGross: Math.round(72 + req.user.handicapIndex - 2),
|
||||
firPercentage: Math.max(30, Math.min(85, 75 - req.user.handicapIndex * 1.5)),
|
||||
girPercentage: Math.max(20, Math.min(80, 70 - req.user.handicapIndex * 2)),
|
||||
puttsPerRound: Number((29 + req.user.handicapIndex * 0.25).toFixed(1)),
|
||||
scramblePercentage: Math.max(15, Math.min(70, 60 - req.user.handicapIndex * 1.8)),
|
||||
sandSavePercentage: Math.max(10, Math.min(65, 50 - req.user.handicapIndex * 1.6)),
|
||||
penaltiesPerRound: Number((0.4 + req.user.handicapIndex * 0.1).toFixed(1)),
|
||||
isPublic: req.user.isPublic,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Default comparison for guest viewing
|
||||
userStats = {
|
||||
id: 'guest_view',
|
||||
username: 'you',
|
||||
name: 'Your Profile',
|
||||
handicapIndex: 14.0,
|
||||
homeClub: 'My Home Course',
|
||||
roundsCount: 4,
|
||||
scoringAverage: 84.5,
|
||||
bestGross: 81,
|
||||
firPercentage: 57.1,
|
||||
girPercentage: 44.4,
|
||||
puttsPerRound: 32.0,
|
||||
scramblePercentage: 38.0,
|
||||
sandSavePercentage: 33.3,
|
||||
penaltiesPerRound: 1.5,
|
||||
isPublic: true,
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate Community Average across all public golfers
|
||||
const n = publicGolfers.length;
|
||||
const commAvg = {
|
||||
scoringAverage: Number((publicGolfers.reduce((s, g) => s + g.scoringAverage, 0) / n).toFixed(1)),
|
||||
firPercentage: Number((publicGolfers.reduce((s, g) => s + g.firPercentage, 0) / n).toFixed(1)),
|
||||
girPercentage: Number((publicGolfers.reduce((s, g) => s + g.girPercentage, 0) / n).toFixed(1)),
|
||||
puttsPerRound: Number((publicGolfers.reduce((s, g) => s + g.puttsPerRound, 0) / n).toFixed(1)),
|
||||
scramblePercentage: Number((publicGolfers.reduce((s, g) => s + g.scramblePercentage, 0) / n).toFixed(1)),
|
||||
sandSavePercentage: Number((publicGolfers.reduce((s, g) => s + g.sandSavePercentage, 0) / n).toFixed(1)),
|
||||
penaltiesPerRound: Number((publicGolfers.reduce((s, g) => s + g.penaltiesPerRound, 0) / n).toFixed(1)),
|
||||
totalPublicRounds: publicGolfers.reduce((s, g) => s + g.roundsCount, 0),
|
||||
totalPublicGolfers: n,
|
||||
};
|
||||
|
||||
// Calculate Handicap Bracket Average
|
||||
const hcp = userStats.handicapIndex;
|
||||
let bracketName = 'Mid-Handicap (5–15)';
|
||||
let bracketGolfers = publicGolfers.filter((g) => g.handicapIndex >= 5 && g.handicapIndex <= 15);
|
||||
|
||||
if (hcp < 5) {
|
||||
bracketName = 'Low Handicap / Scratch (< 5)';
|
||||
bracketGolfers = publicGolfers.filter((g) => g.handicapIndex < 5);
|
||||
} else if (hcp > 15) {
|
||||
bracketName = 'High-Handicap (> 15)';
|
||||
bracketGolfers = publicGolfers.filter((g) => g.handicapIndex > 15);
|
||||
}
|
||||
|
||||
if (bracketGolfers.length === 0) {
|
||||
bracketGolfers = publicGolfers;
|
||||
}
|
||||
|
||||
const bn = bracketGolfers.length;
|
||||
const bracketAvg = {
|
||||
scoringAverage: Number((bracketGolfers.reduce((s, g) => s + g.scoringAverage, 0) / bn).toFixed(1)),
|
||||
firPercentage: Number((bracketGolfers.reduce((s, g) => s + g.firPercentage, 0) / bn).toFixed(1)),
|
||||
girPercentage: Number((bracketGolfers.reduce((s, g) => s + g.girPercentage, 0) / bn).toFixed(1)),
|
||||
puttsPerRound: Number((bracketGolfers.reduce((s, g) => s + g.puttsPerRound, 0) / bn).toFixed(1)),
|
||||
scramblePercentage: Number((bracketGolfers.reduce((s, g) => s + g.scramblePercentage, 0) / bn).toFixed(1)),
|
||||
sandSavePercentage: Number((bracketGolfers.reduce((s, g) => s + g.sandSavePercentage, 0) / bn).toFixed(1)),
|
||||
penaltiesPerRound: Number((bracketGolfers.reduce((s, g) => s + g.penaltiesPerRound, 0) / bn).toFixed(1)),
|
||||
};
|
||||
|
||||
// Helper for percentile calculation
|
||||
const calcPercentile = (val: number, arr: number[], lowerIsBetter: boolean): number => {
|
||||
if (arr.length <= 1) return 50;
|
||||
let count = 0;
|
||||
for (const item of arr) {
|
||||
if (lowerIsBetter ? val < item : val > item) {
|
||||
count++;
|
||||
} else if (val === item) {
|
||||
count += 0.5;
|
||||
}
|
||||
}
|
||||
return Math.round((count / arr.length) * 100);
|
||||
};
|
||||
|
||||
const scoringValues = publicGolfers.map((g) => g.scoringAverage);
|
||||
const firValues = publicGolfers.map((g) => g.firPercentage);
|
||||
const girValues = publicGolfers.map((g) => g.girPercentage);
|
||||
const puttsValues = publicGolfers.map((g) => g.puttsPerRound);
|
||||
const scrambleValues = publicGolfers.map((g) => g.scramblePercentage);
|
||||
const sandValues = publicGolfers.map((g) => g.sandSavePercentage);
|
||||
const penaltyValues = publicGolfers.map((g) => g.penaltiesPerRound);
|
||||
|
||||
const metrics: ComparisonMetric[] = [
|
||||
{
|
||||
key: 'scoringAverage',
|
||||
label: '18-Hole Scoring Average',
|
||||
userValue: userStats.scoringAverage,
|
||||
communityAverage: commAvg.scoringAverage,
|
||||
bracketAverage: bracketAvg.scoringAverage,
|
||||
unit: 'strokes',
|
||||
lowerIsBetter: true,
|
||||
differenceToAverage: Number((userStats.scoringAverage - commAvg.scoringAverage).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.scoringAverage, scoringValues, true),
|
||||
},
|
||||
{
|
||||
key: 'firPercentage',
|
||||
label: 'Fairway Accuracy (FIR)',
|
||||
userValue: userStats.firPercentage,
|
||||
communityAverage: commAvg.firPercentage,
|
||||
bracketAverage: bracketAvg.firPercentage,
|
||||
unit: '%',
|
||||
lowerIsBetter: false,
|
||||
differenceToAverage: Number((userStats.firPercentage - commAvg.firPercentage).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.firPercentage, firValues, false),
|
||||
},
|
||||
{
|
||||
key: 'girPercentage',
|
||||
label: 'Greens in Regulation (GIR)',
|
||||
userValue: userStats.girPercentage,
|
||||
communityAverage: commAvg.girPercentage,
|
||||
bracketAverage: bracketAvg.girPercentage,
|
||||
unit: '%',
|
||||
lowerIsBetter: false,
|
||||
differenceToAverage: Number((userStats.girPercentage - commAvg.girPercentage).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.girPercentage, girValues, false),
|
||||
},
|
||||
{
|
||||
key: 'puttsPerRound',
|
||||
label: 'Putts Per Round (PPR)',
|
||||
userValue: userStats.puttsPerRound,
|
||||
communityAverage: commAvg.puttsPerRound,
|
||||
bracketAverage: bracketAvg.puttsPerRound,
|
||||
unit: 'putts',
|
||||
lowerIsBetter: true,
|
||||
differenceToAverage: Number((userStats.puttsPerRound - commAvg.puttsPerRound).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.puttsPerRound, puttsValues, true),
|
||||
},
|
||||
{
|
||||
key: 'scramblePercentage',
|
||||
label: 'Scrambling / Up & Down %',
|
||||
userValue: userStats.scramblePercentage,
|
||||
communityAverage: commAvg.scramblePercentage,
|
||||
bracketAverage: bracketAvg.scramblePercentage,
|
||||
unit: '%',
|
||||
lowerIsBetter: false,
|
||||
differenceToAverage: Number((userStats.scramblePercentage - commAvg.scramblePercentage).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.scramblePercentage, scrambleValues, false),
|
||||
},
|
||||
{
|
||||
key: 'sandSavePercentage',
|
||||
label: 'Sand Save Conversion %',
|
||||
userValue: userStats.sandSavePercentage,
|
||||
communityAverage: commAvg.sandSavePercentage,
|
||||
bracketAverage: bracketAvg.sandSavePercentage,
|
||||
unit: '%',
|
||||
lowerIsBetter: false,
|
||||
differenceToAverage: Number((userStats.sandSavePercentage - commAvg.sandSavePercentage).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.sandSavePercentage, sandValues, false),
|
||||
},
|
||||
{
|
||||
key: 'penaltiesPerRound',
|
||||
label: 'Penalty Strokes Per Round',
|
||||
userValue: userStats.penaltiesPerRound,
|
||||
communityAverage: commAvg.penaltiesPerRound,
|
||||
bracketAverage: bracketAvg.penaltiesPerRound,
|
||||
unit: 'strokes',
|
||||
lowerIsBetter: true,
|
||||
differenceToAverage: Number((userStats.penaltiesPerRound - commAvg.penaltiesPerRound).toFixed(1)),
|
||||
percentileRank: calcPercentile(userStats.penaltiesPerRound, penaltyValues, true),
|
||||
},
|
||||
];
|
||||
|
||||
const responseData: CommunityComparisonData = {
|
||||
userStats,
|
||||
communityAverage: commAvg,
|
||||
metrics,
|
||||
handicapBracket: bracketName,
|
||||
};
|
||||
|
||||
res.json(responseData);
|
||||
} catch (error) {
|
||||
console.error('Community compare error:', error);
|
||||
res.status(500).json({ error: 'Failed to calculate community comparison' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Router, Response } from 'express';
|
||||
import { db } from '../db';
|
||||
import { authMiddleware, AuthenticatedRequest } from '../authMiddleware';
|
||||
import { Round, Course } from '../../types/golf';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// --- Get All Rounds for User ---
|
||||
router.get('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const rounds = db.getRounds(req.user.id);
|
||||
return res.json(rounds);
|
||||
});
|
||||
|
||||
// --- Save / Update Single Round ---
|
||||
router.post('/', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const round: Round = req.body;
|
||||
|
||||
if (!round || !round.id || !round.courseId) {
|
||||
return res.status(400).json({ error: 'Invalid round payload' });
|
||||
}
|
||||
|
||||
const saved = db.saveRound(req.user.id, round);
|
||||
return res.json(saved);
|
||||
});
|
||||
|
||||
// --- Batch Sync Rounds (e.g. migrating local offline rounds to cloud account) ---
|
||||
router.post('/sync-batch', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const { rounds } = req.body;
|
||||
|
||||
if (!Array.isArray(rounds)) {
|
||||
return res.status(400).json({ error: 'Expected an array of rounds' });
|
||||
}
|
||||
|
||||
const updatedRounds = db.syncRoundsBatch(req.user.id, rounds);
|
||||
return res.json({ count: updatedRounds.length, rounds: updatedRounds });
|
||||
});
|
||||
|
||||
// --- Delete Round ---
|
||||
router.delete('/:id', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const success = db.deleteRound(req.user.id, req.params.id);
|
||||
if (!success) {
|
||||
return res.status(404).json({ error: 'Round not found' });
|
||||
}
|
||||
return res.json({ success: true });
|
||||
});
|
||||
|
||||
// --- Custom Courses ---
|
||||
router.get('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
return res.json(db.getCustomCourses(req.user.id));
|
||||
});
|
||||
|
||||
router.post('/courses/custom', authMiddleware, (req: AuthenticatedRequest, res: Response) => {
|
||||
if (!req.user) return res.status(401).json({ error: 'Not authenticated' });
|
||||
const course: Course = req.body;
|
||||
if (!course || !course.id || !course.name) {
|
||||
return res.status(400).json({ error: 'Invalid course payload' });
|
||||
}
|
||||
const saved = db.saveCustomCourse(req.user.id, course);
|
||||
return res.json(saved);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,168 @@
|
||||
import { User, AuthResponse, PublicGolferStats, CommunityComparisonData } from '../types/auth';
|
||||
import { Round, TrackingSettings, Course } from '../types/golf';
|
||||
|
||||
const AUTH_TOKEN_KEY = 'pinpoint_auth_token_v1';
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return localStorage.getItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
|
||||
export function setStoredToken(token: string | null): void {
|
||||
if (token) {
|
||||
localStorage.setItem(AUTH_TOKEN_KEY, token);
|
||||
} else {
|
||||
localStorage.removeItem(AUTH_TOKEN_KEY);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
const token = getStoredToken();
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.headers as Record<string, string>),
|
||||
};
|
||||
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMsg = `Request failed: ${response.statusText}`;
|
||||
try {
|
||||
const errorJson = await response.json();
|
||||
if (errorJson && errorJson.error) {
|
||||
errorMsg = errorJson.error;
|
||||
}
|
||||
} catch {
|
||||
// Ignore json parse error
|
||||
}
|
||||
throw new Error(errorMsg);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async register(data: {
|
||||
email: string;
|
||||
username: string;
|
||||
password: string;
|
||||
name?: string;
|
||||
handicapIndex?: number;
|
||||
homeClub?: string;
|
||||
isPublic?: boolean;
|
||||
}): Promise<AuthResponse & { settings: TrackingSettings }> {
|
||||
return request<AuthResponse & { settings: TrackingSettings }>('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async login(identifier: string, password: string): Promise<AuthResponse & { settings: TrackingSettings }> {
|
||||
return request<AuthResponse & { settings: TrackingSettings }>('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ identifier, password }),
|
||||
});
|
||||
},
|
||||
|
||||
async getMe(): Promise<{ user: User; settings: TrackingSettings }> {
|
||||
return request<{ user: User; settings: TrackingSettings }>('/api/auth/me');
|
||||
},
|
||||
|
||||
async updateProfile(updates: Partial<User>): Promise<{ user: User }> {
|
||||
return request<{ user: User }>('/api/auth/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(updates),
|
||||
});
|
||||
},
|
||||
|
||||
async ssoLogin(data: {
|
||||
provider: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
name?: string;
|
||||
avatarUrl?: string;
|
||||
authentikServerUrl?: string;
|
||||
}): Promise<AuthResponse & { settings: TrackingSettings }> {
|
||||
return request<AuthResponse & { settings: TrackingSettings }>('/api/auth/oauth/sso-login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
},
|
||||
|
||||
async getProviders(): Promise<{
|
||||
providers: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
isConfigured: boolean;
|
||||
issuerUrl?: string;
|
||||
authUrlEndpoint: string;
|
||||
}>;
|
||||
}> {
|
||||
return request('/api/auth/oauth/providers');
|
||||
},
|
||||
|
||||
async getSettings(): Promise<TrackingSettings> {
|
||||
return request<TrackingSettings>('/api/auth/settings');
|
||||
},
|
||||
|
||||
async updateSettings(settings: TrackingSettings): Promise<TrackingSettings> {
|
||||
return request<TrackingSettings>('/api/auth/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const roundsApi = {
|
||||
async getRounds(): Promise<Round[]> {
|
||||
return request<Round[]>('/api/rounds');
|
||||
},
|
||||
|
||||
async saveRound(round: Round): Promise<Round> {
|
||||
return request<Round>('/api/rounds', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(round),
|
||||
});
|
||||
},
|
||||
|
||||
async syncBatch(rounds: Round[]): Promise<{ count: number; rounds: Round[] }> {
|
||||
return request<{ count: number; rounds: Round[] }>('/api/rounds/sync-batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ rounds }),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteRound(id: string): Promise<{ success: boolean }> {
|
||||
return request<{ success: boolean }>(`/api/rounds/${id}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
},
|
||||
|
||||
async getCustomCourses(): Promise<Course[]> {
|
||||
return request<Course[]>('/api/rounds/courses/custom');
|
||||
},
|
||||
|
||||
async saveCustomCourse(course: Course): Promise<Course> {
|
||||
return request<Course>('/api/rounds/courses/custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(course),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export const communityApi = {
|
||||
async getLeaderboard(): Promise<{ totalCount: number; golfers: PublicGolferStats[] }> {
|
||||
return request<{ totalCount: number; golfers: PublicGolferStats[] }>('/api/community/leaderboard');
|
||||
},
|
||||
|
||||
async getComparison(): Promise<CommunityComparisonData> {
|
||||
return request<CommunityComparisonData>('/api/community/compare');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import { Course, Hole } from '../types/golf';
|
||||
import { generateHoleCoordinates } from '../utils/geoUtils';
|
||||
|
||||
export interface ApiGolfCourseSearchResult {
|
||||
id: string;
|
||||
name: string;
|
||||
course_name?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
country?: string;
|
||||
par?: number;
|
||||
holes?: number;
|
||||
website?: string;
|
||||
phone?: string;
|
||||
type?: string;
|
||||
description?: string;
|
||||
latitude?: number;
|
||||
longitude?: number;
|
||||
}
|
||||
|
||||
export interface ApiScorecardHole {
|
||||
hole: number;
|
||||
par: number;
|
||||
yardage?: number;
|
||||
handicap?: number;
|
||||
}
|
||||
|
||||
export interface ApiGolfCourseDetail extends ApiGolfCourseSearchResult {
|
||||
scorecard?: ApiScorecardHole[];
|
||||
tees?: Array<{
|
||||
name: string;
|
||||
par?: number;
|
||||
rating?: number;
|
||||
slope?: number;
|
||||
yardage?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
const OPENGOLF_BASE_URL = 'https://api.opengolfapi.org/v1';
|
||||
|
||||
/**
|
||||
* Searches golf courses worldwide using OpenGolfAPI.
|
||||
*/
|
||||
export async function searchGolfCoursesApi(query: string): Promise<ApiGolfCourseSearchResult[]> {
|
||||
if (!query || query.trim().length < 2) return [];
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 7000);
|
||||
|
||||
const response = await fetch(
|
||||
`${OPENGOLF_BASE_URL}/courses/search?q=${encodeURIComponent(query.trim())}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`API response error (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data && Array.isArray(data.courses)) {
|
||||
return data.courses;
|
||||
}
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
console.warn('OpenGolfAPI search error:', error?.message || error);
|
||||
// Throw error so caller can display a friendly message or fallback
|
||||
throw new Error('Unable to connect to OpenGolfAPI. Please check connection or use custom courses.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches full course details and scorecard holes.
|
||||
*/
|
||||
export async function fetchCourseDetailsApi(courseId: string): Promise<Course> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 7000);
|
||||
|
||||
const response = await fetch(`${OPENGOLF_BASE_URL}/courses/${courseId}`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Course fetch error (${response.status})`);
|
||||
}
|
||||
|
||||
const data: ApiGolfCourseDetail = await response.json();
|
||||
return convertApiCourseToCourse(data);
|
||||
} catch (error: any) {
|
||||
console.warn('Error fetching course detail from API:', error?.message || error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an OpenGolfAPI course record to our app's internal Course model.
|
||||
*/
|
||||
export function convertApiCourseToCourse(apiData: ApiGolfCourseDetail): Course {
|
||||
const holesCount = apiData.holes === 9 ? 9 : 18;
|
||||
const coursePar = apiData.par || 72;
|
||||
|
||||
// Generate 18 holes using API scorecard if available, or realistic defaults
|
||||
const holes: Hole[] = [];
|
||||
|
||||
const defaultPars = [4, 4, 3, 5, 4, 4, 3, 4, 5, 4, 4, 3, 5, 4, 4, 3, 4, 5];
|
||||
const defaultYards = [385, 410, 175, 520, 395, 425, 160, 440, 515, 390, 420, 185, 535, 405, 370, 195, 430, 540];
|
||||
|
||||
const centerLat = apiData.latitude || 36.5685;
|
||||
const centerLng = apiData.longitude || -121.949;
|
||||
let currentTee = { lat: centerLat, lng: centerLng };
|
||||
|
||||
for (let i = 1; i <= holesCount; i++) {
|
||||
const apiHole = apiData.scorecard?.find((h) => h.hole === i);
|
||||
const par = apiHole?.par || defaultPars[(i - 1) % defaultPars.length];
|
||||
const yardage = apiHole?.yardage || defaultYards[(i - 1) % defaultYards.length];
|
||||
const handicapIndex = apiHole?.handicap || i;
|
||||
const bearing = (i * 35) % 360;
|
||||
const distanceMeters = Math.round(yardage * 0.9144);
|
||||
const coords = generateHoleCoordinates(currentTee, distanceMeters, bearing);
|
||||
|
||||
holes.push({
|
||||
holeNumber: i,
|
||||
par,
|
||||
yardage,
|
||||
handicapIndex,
|
||||
coordinates: coords,
|
||||
});
|
||||
|
||||
currentTee = {
|
||||
lat: coords.greenBack.lat + 0.0003 * Math.cos((bearing * Math.PI) / 180),
|
||||
lng: coords.greenBack.lng + 0.0003 * Math.sin((bearing * Math.PI) / 180),
|
||||
};
|
||||
}
|
||||
|
||||
const locationParts = [apiData.city, apiData.state, apiData.country].filter(Boolean);
|
||||
const location = locationParts.length > 0 ? locationParts.join(', ') : 'Global Golf Course';
|
||||
|
||||
return {
|
||||
id: `api-${apiData.id}`,
|
||||
apiId: apiData.id,
|
||||
name: apiData.name || apiData.course_name || 'Golf Course',
|
||||
location,
|
||||
city: apiData.city,
|
||||
state: apiData.state,
|
||||
website: apiData.website,
|
||||
description: apiData.description,
|
||||
tee: 'Tournament Blue',
|
||||
parTotal: coursePar,
|
||||
rating: 72.0,
|
||||
slope: 125,
|
||||
holes,
|
||||
isCustom: false,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Course, WeatherForecast } from '../types/golf';
|
||||
|
||||
/**
|
||||
* Maps WMO Weather Interpretation Codes to user-friendly titles and Lucide icon keys.
|
||||
* Reference: World Meteorological Organization (WMO) Code Table 4677
|
||||
*/
|
||||
export function getWmoWeatherDetails(code: number): {
|
||||
description: string;
|
||||
iconName: 'Sun' | 'CloudSun' | 'Cloud' | 'CloudRain' | 'CloudLightning' | 'CloudSnow' | 'CloudFog' | 'CloudDrizzle';
|
||||
} {
|
||||
switch (code) {
|
||||
case 0:
|
||||
return { description: 'Clear Sky', iconName: 'Sun' };
|
||||
case 1:
|
||||
return { description: 'Mainly Clear', iconName: 'CloudSun' };
|
||||
case 2:
|
||||
return { description: 'Partly Cloudy', iconName: 'CloudSun' };
|
||||
case 3:
|
||||
return { description: 'Overcast', iconName: 'Cloud' };
|
||||
case 45:
|
||||
case 48:
|
||||
return { description: 'Fog / Depositing Rime Fog', iconName: 'CloudFog' };
|
||||
case 51:
|
||||
case 53:
|
||||
case 55:
|
||||
return { description: 'Drizzle', iconName: 'CloudDrizzle' };
|
||||
case 56:
|
||||
case 57:
|
||||
return { description: 'Freezing Drizzle', iconName: 'CloudDrizzle' };
|
||||
case 61:
|
||||
return { description: 'Slight Rain', iconName: 'CloudRain' };
|
||||
case 63:
|
||||
return { description: 'Moderate Rain', iconName: 'CloudRain' };
|
||||
case 65:
|
||||
return { description: 'Heavy Rain', iconName: 'CloudRain' };
|
||||
case 66:
|
||||
case 67:
|
||||
return { description: 'Freezing Rain', iconName: 'CloudRain' };
|
||||
case 71:
|
||||
case 73:
|
||||
case 75:
|
||||
return { description: 'Snow Fall', iconName: 'CloudSnow' };
|
||||
case 77:
|
||||
return { description: 'Snow Grains', iconName: 'CloudSnow' };
|
||||
case 80:
|
||||
case 81:
|
||||
case 82:
|
||||
return { description: 'Rain Showers', iconName: 'CloudRain' };
|
||||
case 85:
|
||||
case 86:
|
||||
return { description: 'Snow Showers', iconName: 'CloudSnow' };
|
||||
case 95:
|
||||
return { description: 'Thunderstorm', iconName: 'CloudLightning' };
|
||||
case 96:
|
||||
case 99:
|
||||
return { description: 'Thunderstorm with Hail', iconName: 'CloudLightning' };
|
||||
default:
|
||||
return { description: 'Fair Weather', iconName: 'CloudSun' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts wind bearing in degrees to 16-point compass directions.
|
||||
*/
|
||||
export function degreesToCompass(degrees: number): string {
|
||||
const directions = [
|
||||
'N', 'NNE', 'NE', 'ENE',
|
||||
'E', 'ESE', 'SE', 'SSE',
|
||||
'S', 'SSW', 'SW', 'WSW',
|
||||
'W', 'WNW', 'NW', 'NNW',
|
||||
];
|
||||
const index = Math.round(((degrees % 360) / 22.5)) % 16;
|
||||
return directions[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Categorizes wind speed into standard golf wind conditions.
|
||||
* - Calm: 0 - 8 km/h
|
||||
* - Breezy: 9 - 20 km/h
|
||||
* - Strong: 21 - 35 km/h
|
||||
* - Gusty: > 35 km/h
|
||||
*/
|
||||
export function categorizeWindSpeed(speedKmH: number): 'Calm' | 'Breezy' | 'Strong' | 'Gusty' {
|
||||
if (speedKmH <= 8) return 'Calm';
|
||||
if (speedKmH <= 20) return 'Breezy';
|
||||
if (speedKmH <= 35) return 'Strong';
|
||||
return 'Gusty';
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches real-time weather and forecast using the Open-Meteo open weather API.
|
||||
* Free, open-source meteorological database requiring no API key.
|
||||
*/
|
||||
export async function fetchWeatherFromOpenMeteo(
|
||||
lat: number,
|
||||
lng: number,
|
||||
locationLabel: string = 'Current Location',
|
||||
source: 'gps' | 'course' = 'gps'
|
||||
): Promise<WeatherForecast> {
|
||||
const url = `https://api.open-meteo.com/v1/forecast?latitude=${lat.toFixed(4)}&longitude=${lng.toFixed(
|
||||
4
|
||||
)}¤t=temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m,wind_direction_10m&hourly=precipitation_probability&forecast_hours=3&timezone=auto`;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Open Weather API request failed (${response.status})`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const current = data.current || {};
|
||||
const hourly = data.hourly || {};
|
||||
|
||||
const tempC = Math.round((current.temperature_2m ?? 20) * 10) / 10;
|
||||
const tempF = Math.round(((tempC * 9) / 5 + 32) * 10) / 10;
|
||||
const apparentTempC = current.apparent_temperature ? Math.round(current.apparent_temperature * 10) / 10 : undefined;
|
||||
const windSpeedKmH = Math.round((current.wind_speed_10m ?? 12) * 10) / 10;
|
||||
const windDirectionDeg = Math.round(current.wind_direction_10m ?? 0);
|
||||
const windCompass = degreesToCompass(windDirectionDeg);
|
||||
const windCat = categorizeWindSpeed(windSpeedKmH);
|
||||
|
||||
// Precipitation probability from current hour (fallback to 0)
|
||||
const precipProb =
|
||||
Array.isArray(hourly.precipitation_probability) && hourly.precipitation_probability.length > 0
|
||||
? hourly.precipitation_probability[0]
|
||||
: 0;
|
||||
|
||||
const weatherCode = current.weather_code ?? 0;
|
||||
const weatherDetails = getWmoWeatherDetails(weatherCode);
|
||||
|
||||
return {
|
||||
temperature: tempC,
|
||||
temperatureF: tempF,
|
||||
apparentTemperature: apparentTempC,
|
||||
windSpeed: windSpeedKmH,
|
||||
windDirection: windDirectionDeg,
|
||||
windDirectionCompass: windCompass,
|
||||
windCategory: windCat,
|
||||
precipitationProbability: Math.min(100, Math.max(0, precipProb)),
|
||||
weatherCode,
|
||||
conditionText: weatherDetails.description,
|
||||
relativeHumidity: current.relative_humidity_2m,
|
||||
locationLabel,
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
fetchedAt: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the user's current GPS position using browser Geolocation API.
|
||||
*/
|
||||
export function getUserGpsPosition(): Promise<{ lat: number; lng: number; accuracy: number }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!navigator.geolocation) {
|
||||
reject(new Error('Geolocation is not supported by your browser'));
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
resolve({
|
||||
lat: position.coords.latitude,
|
||||
lng: position.coords.longitude,
|
||||
accuracy: position.coords.accuracy,
|
||||
});
|
||||
},
|
||||
(error) => {
|
||||
let msg = 'Unable to retrieve location';
|
||||
if (error.code === error.PERMISSION_DENIED) {
|
||||
msg = 'Location permission was denied. Please allow GPS access or use the course location.';
|
||||
} else if (error.code === error.POSITION_UNAVAILABLE) {
|
||||
msg = 'GPS location is currently unavailable.';
|
||||
} else if (error.code === error.TIMEOUT) {
|
||||
msg = 'GPS location request timed out.';
|
||||
}
|
||||
reject(new Error(msg));
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000,
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts best known GPS coordinates for a course.
|
||||
*/
|
||||
export function getCourseCoordinates(course: Course): { lat: number; lng: number; label: string } {
|
||||
// Check hole coordinates
|
||||
const firstHoleWithCoords = course.holes.find((h) => h.coordinates?.tee);
|
||||
if (firstHoleWithCoords?.coordinates?.tee) {
|
||||
return {
|
||||
lat: firstHoleWithCoords.coordinates.tee.lat,
|
||||
lng: firstHoleWithCoords.coordinates.tee.lng,
|
||||
label: `${course.name} (${course.location})`,
|
||||
};
|
||||
}
|
||||
|
||||
// Common golf courses fallback
|
||||
if (course.name.toLowerCase().includes('pebble')) {
|
||||
return { lat: 36.5688, lng: -121.9507, label: 'Pebble Beach, CA' };
|
||||
}
|
||||
if (course.name.toLowerCase().includes('pine')) {
|
||||
return { lat: 39.7915, lng: -74.9723, label: 'Clementon, NJ' };
|
||||
}
|
||||
if (course.name.toLowerCase().includes('st andrews')) {
|
||||
return { lat: 56.3432, lng: -2.8028, label: 'St Andrews, Scotland' };
|
||||
}
|
||||
if (course.name.toLowerCase().includes('augusta')) {
|
||||
return { lat: 33.5021, lng: -82.0226, label: 'Augusta, GA' };
|
||||
}
|
||||
|
||||
// Default golf resort location (Monterey Peninsula)
|
||||
return { lat: 36.5688, lng: -121.9507, label: `${course.name}` };
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Round, TrackingSettings } from './golf';
|
||||
|
||||
export type AuthProvider = 'local' | 'google' | 'authentik' | 'github' | 'demo';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
handicapIndex: number;
|
||||
homeClub: string;
|
||||
isPublic: boolean;
|
||||
provider: AuthProvider;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface PublicGolferStats {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
avatarUrl?: string;
|
||||
handicapIndex: number;
|
||||
homeClub: string;
|
||||
roundsCount: number;
|
||||
scoringAverage: number;
|
||||
bestGross: number;
|
||||
firPercentage: number;
|
||||
girPercentage: number;
|
||||
puttsPerRound: number;
|
||||
scramblePercentage: number;
|
||||
sandSavePercentage: number;
|
||||
penaltiesPerRound: number;
|
||||
isPublic: boolean;
|
||||
}
|
||||
|
||||
export interface ComparisonMetric {
|
||||
key: string;
|
||||
label: string;
|
||||
userValue: number;
|
||||
communityAverage: number;
|
||||
bracketAverage: number;
|
||||
unit: string;
|
||||
lowerIsBetter: boolean;
|
||||
differenceToAverage: number;
|
||||
percentileRank: number; // 0 to 100
|
||||
}
|
||||
|
||||
export interface CommunityComparisonData {
|
||||
userStats: PublicGolferStats;
|
||||
communityAverage: {
|
||||
scoringAverage: number;
|
||||
firPercentage: number;
|
||||
girPercentage: number;
|
||||
puttsPerRound: number;
|
||||
scramblePercentage: number;
|
||||
sandSavePercentage: number;
|
||||
penaltiesPerRound: number;
|
||||
totalPublicRounds: number;
|
||||
totalPublicGolfers: number;
|
||||
};
|
||||
metrics: ComparisonMetric[];
|
||||
handicapBracket: string;
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
export type PenaltyType = 'water' | 'ob' | 'lost_ball' | 'unplayable' | 'other';
|
||||
|
||||
export interface PenaltyDetail {
|
||||
id: string;
|
||||
type: PenaltyType;
|
||||
strokes: number;
|
||||
shotNumber?: number; // Shot 1 (Tee), Shot 2, etc.
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export type FIRStatus = 'hit' | 'left' | 'right' | 'short' | 'na';
|
||||
|
||||
export type GIRStatus = 'hit' | 'miss_short' | 'miss_long' | 'miss_left' | 'miss_right' | 'miss';
|
||||
|
||||
export interface BunkerDetail {
|
||||
hit: boolean;
|
||||
fairwayBunker: boolean;
|
||||
greensideBunker: boolean;
|
||||
sandSave: boolean | null; // Did the player make par or better out of a greenside bunker?
|
||||
strokesInBunker?: number;
|
||||
}
|
||||
|
||||
export interface HoleScore {
|
||||
holeNumber: number;
|
||||
grossScore: number;
|
||||
fir: FIRStatus;
|
||||
gir: GIRStatus;
|
||||
putts: number;
|
||||
foregreenPutts: number; // Putts taken from off-green / foregreen (fringe)
|
||||
firstPuttFromFringe: boolean;
|
||||
firstPuttDistanceFt: number | null;
|
||||
penalties: PenaltyDetail[];
|
||||
bunker: BunkerDetail;
|
||||
upAndDown: boolean | null; // Scrambled par or better after missing GIR
|
||||
driveClub?: string;
|
||||
driveDistanceYds?: number | null;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export interface LatLngLiteral {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
export interface HoleCoordinates {
|
||||
tee: LatLngLiteral;
|
||||
fairway?: LatLngLiteral;
|
||||
greenFront: LatLngLiteral;
|
||||
greenCenter: LatLngLiteral;
|
||||
greenBack: LatLngLiteral;
|
||||
}
|
||||
|
||||
export interface Hole {
|
||||
holeNumber: number;
|
||||
par: number; // 3, 4, 5
|
||||
yardage: number; // hole distance in course units (meters or yards)
|
||||
handicapIndex: number; // 1 to 18
|
||||
coordinates?: HoleCoordinates;
|
||||
}
|
||||
|
||||
export interface Course {
|
||||
id: string;
|
||||
name: string;
|
||||
location: string;
|
||||
tee: string; // 'Championship Black' | 'Tournament Blue' | 'Member White' | 'Forward Red'
|
||||
parTotal: number;
|
||||
rating?: number;
|
||||
slope?: number;
|
||||
holes: Hole[];
|
||||
isCustom?: boolean;
|
||||
apiId?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
website?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export type AchievementTier = 'bronze' | 'silver' | 'gold' | 'platinum';
|
||||
export type AchievementCategory = 'scoring' | 'accuracy' | 'short_game' | 'special';
|
||||
|
||||
export interface Achievement {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: AchievementCategory;
|
||||
tier: AchievementTier;
|
||||
iconName: string;
|
||||
isUnlocked?: boolean;
|
||||
unlockedAt?: string;
|
||||
unlockedRoundId?: string;
|
||||
unlockedRoundName?: string;
|
||||
}
|
||||
|
||||
export interface TrackingSettings {
|
||||
// Fairways
|
||||
trackFIR: boolean;
|
||||
trackFIRDirection: boolean;
|
||||
|
||||
// Greens in Regulation
|
||||
trackGIR: boolean;
|
||||
trackGIRDirection: boolean;
|
||||
|
||||
// Putting
|
||||
trackPutts: boolean;
|
||||
trackForegreen: boolean; // Foregreen / fringe putts
|
||||
trackFirstPuttDistance: boolean;
|
||||
|
||||
// Penalties
|
||||
trackPenalties: boolean;
|
||||
trackPenaltyTypes: boolean; // Water, OB, Lost Ball, Unplayable
|
||||
|
||||
// Bunkers
|
||||
trackBunkers: boolean;
|
||||
trackBunkerTypes: boolean; // Fairway vs Greenside distinction
|
||||
trackSandSaves: boolean;
|
||||
|
||||
// Scrambling / Up & Down
|
||||
trackUpAndDown: boolean;
|
||||
|
||||
// Club & Distance
|
||||
trackDriveClub: boolean;
|
||||
trackDriveDistance: boolean;
|
||||
|
||||
// Notes
|
||||
trackHoleNotes: boolean;
|
||||
|
||||
// Units
|
||||
distanceUnit: 'yards' | 'meters';
|
||||
puttDistanceUnit: 'feet' | 'meters';
|
||||
}
|
||||
|
||||
export interface Round {
|
||||
id: string;
|
||||
date: string;
|
||||
courseId: string;
|
||||
courseName: string;
|
||||
teeName: string;
|
||||
coursePar: number;
|
||||
holesPlayed: 18 | 9;
|
||||
nineType?: 'all' | 'front' | 'back';
|
||||
scores: Record<number, HoleScore>;
|
||||
isCompleted: boolean;
|
||||
settingsSnapshot: TrackingSettings;
|
||||
playerHandicap: number;
|
||||
weather?: string;
|
||||
wind?: 'Calm' | 'Breezy' | 'Strong' | 'Gusty';
|
||||
weatherData?: WeatherForecast;
|
||||
}
|
||||
|
||||
export interface WeatherForecast {
|
||||
temperature: number; // Celsius
|
||||
temperatureF: number; // Fahrenheit
|
||||
apparentTemperature?: number; // Celsius
|
||||
windSpeed: number; // km/h
|
||||
windDirection?: number; // degrees 0-360
|
||||
windDirectionCompass: string; // e.g. "NE", "SW"
|
||||
windCategory: 'Calm' | 'Breezy' | 'Strong' | 'Gusty';
|
||||
precipitationProbability: number; // 0-100%
|
||||
weatherCode: number; // WMO code
|
||||
conditionText: string; // e.g. "Partly Cloudy", "Clear Sky"
|
||||
relativeHumidity?: number; // percentage
|
||||
locationLabel: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
fetchedAt: string;
|
||||
source: 'gps' | 'course' | 'manual';
|
||||
}
|
||||
|
||||
export type PresetProfile = 'tour' | 'standard' | 'minimal' | 'custom';
|
||||
@@ -0,0 +1,440 @@
|
||||
import { Achievement, Course, Round } from '../types/golf';
|
||||
import { calculateRoundStats } from './golfStats';
|
||||
|
||||
export const ALL_ACHIEVEMENTS: Achievement[] = [
|
||||
// --- Bronze Tiers (Entry Milestones) ---
|
||||
{
|
||||
id: 'ach_first_round',
|
||||
title: 'First Swing',
|
||||
description: 'Complete your first 9 or 18 hole round.',
|
||||
category: 'scoring',
|
||||
tier: 'bronze',
|
||||
iconName: 'Flag',
|
||||
},
|
||||
{
|
||||
id: 'ach_break_100',
|
||||
title: 'Century Broken',
|
||||
description: 'Break 100 in an 18-hole round (Score under 100).',
|
||||
category: 'scoring',
|
||||
tier: 'bronze',
|
||||
iconName: 'Award',
|
||||
},
|
||||
{
|
||||
id: 'ach_fairway_finder',
|
||||
title: 'Fairway Finder',
|
||||
description: 'Hit at least 50% of fairways in a round.',
|
||||
category: 'accuracy',
|
||||
tier: 'bronze',
|
||||
iconName: 'Compass',
|
||||
},
|
||||
{
|
||||
id: 'ach_first_birdie',
|
||||
title: 'Birdie Hunter',
|
||||
description: 'Card your first birdie on any hole.',
|
||||
category: 'scoring',
|
||||
tier: 'bronze',
|
||||
iconName: 'Flame',
|
||||
},
|
||||
{
|
||||
id: 'ach_fringe_whisperer',
|
||||
title: 'Fringe Whisperer',
|
||||
description: 'Make an up-and-down par or birdie using a foregreen (fringe) putt.',
|
||||
category: 'short_game',
|
||||
tier: 'bronze',
|
||||
iconName: 'Crosshair',
|
||||
},
|
||||
{
|
||||
id: 'ach_out_of_sand',
|
||||
title: 'Beach Escape',
|
||||
description: 'Hit a bunker and successfully finish the hole with a score.',
|
||||
category: 'short_game',
|
||||
tier: 'bronze',
|
||||
iconName: 'Sparkles',
|
||||
},
|
||||
|
||||
// --- Silver Tiers (Intermediate Milestones) ---
|
||||
{
|
||||
id: 'ach_sub_90',
|
||||
title: 'Sub-90 Club',
|
||||
description: 'Break 90 in an 18-hole round (Score under 90).',
|
||||
category: 'scoring',
|
||||
tier: 'silver',
|
||||
iconName: 'Trophy',
|
||||
},
|
||||
{
|
||||
id: 'ach_fairway_dominance',
|
||||
title: 'Fairway Dominance',
|
||||
description: 'Hit 70% or more fairways in an 18-hole round.',
|
||||
category: 'accuracy',
|
||||
tier: 'silver',
|
||||
iconName: 'Target',
|
||||
},
|
||||
{
|
||||
id: 'ach_gir_target_locked',
|
||||
title: 'Target Locked',
|
||||
description: 'Hit at least 9 greens in regulation (50%+ GIR).',
|
||||
category: 'accuracy',
|
||||
tier: 'silver',
|
||||
iconName: 'Crosshair',
|
||||
},
|
||||
{
|
||||
id: 'ach_clutch_sand_save',
|
||||
title: 'Clutch Sand Save',
|
||||
description: 'Get up and down from greenside sand for Par or better.',
|
||||
category: 'short_game',
|
||||
tier: 'silver',
|
||||
iconName: 'ShieldCheck',
|
||||
},
|
||||
{
|
||||
id: 'ach_scramble_specialist',
|
||||
title: 'Up & Down Specialist',
|
||||
description: 'Convert at least 3 scrambling up-and-downs in a single round.',
|
||||
category: 'short_game',
|
||||
tier: 'silver',
|
||||
iconName: 'Sparkles',
|
||||
},
|
||||
{
|
||||
id: 'ach_bounce_back',
|
||||
title: 'Bounce Back',
|
||||
description: 'Card a birdie immediately after making a bogey or worse.',
|
||||
category: 'special',
|
||||
tier: 'silver',
|
||||
iconName: 'Zap',
|
||||
},
|
||||
{
|
||||
id: 'ach_clean_sheet',
|
||||
title: 'Clean Sheet',
|
||||
description: 'Finish an 18-hole round with ZERO penalty strokes (no water, OB, or lost ball).',
|
||||
category: 'accuracy',
|
||||
tier: 'silver',
|
||||
iconName: 'CheckCircle2',
|
||||
},
|
||||
|
||||
// --- Gold Tiers (Advanced Feats) ---
|
||||
{
|
||||
id: 'ach_break_80',
|
||||
title: 'Breaking 80',
|
||||
description: 'Shoot 79 or better in an 18-hole round.',
|
||||
category: 'scoring',
|
||||
tier: 'gold',
|
||||
iconName: 'Crown',
|
||||
},
|
||||
{
|
||||
id: 'ach_eagle_eye',
|
||||
title: 'Eagle Eye',
|
||||
description: 'Card an Eagle (2 strokes under par on any hole).',
|
||||
category: 'scoring',
|
||||
tier: 'gold',
|
||||
iconName: 'Sun',
|
||||
},
|
||||
{
|
||||
id: 'ach_birdie_streak',
|
||||
title: 'Birdie Streak',
|
||||
description: 'Make 2 or more birdies in consecutive holes during a round.',
|
||||
category: 'scoring',
|
||||
tier: 'gold',
|
||||
iconName: 'Flame',
|
||||
},
|
||||
{
|
||||
id: 'ach_fairway_sniper',
|
||||
title: 'Fairway Sniper',
|
||||
description: 'Hit 80% or more fairways in an 18-hole round.',
|
||||
category: 'accuracy',
|
||||
tier: 'gold',
|
||||
iconName: 'Target',
|
||||
},
|
||||
{
|
||||
id: 'ach_no_three_putts',
|
||||
title: 'Flatstick Pure',
|
||||
description: 'Complete an 18-hole round with ZERO 3-putts.',
|
||||
category: 'short_game',
|
||||
tier: 'gold',
|
||||
iconName: 'CheckCheck',
|
||||
},
|
||||
{
|
||||
id: 'ach_sand_wizard',
|
||||
title: 'Sand Wizard',
|
||||
description: 'Achieve 100% sand save conversion rate (minimum 2 greenside sand saves).',
|
||||
category: 'short_game',
|
||||
tier: 'gold',
|
||||
iconName: 'Sparkles',
|
||||
},
|
||||
|
||||
// --- Platinum Tiers (Legendary Feats) ---
|
||||
{
|
||||
id: 'ach_scratch_golfer',
|
||||
title: 'Scratch Golfer',
|
||||
description: 'Shoot even par or better across an 18-hole round (Score <= Par).',
|
||||
category: 'scoring',
|
||||
tier: 'platinum',
|
||||
iconName: 'Medal',
|
||||
},
|
||||
{
|
||||
id: 'ach_drove_the_green',
|
||||
title: 'Drove the Green',
|
||||
description: 'Drive a Par 4 green in regulation off the tee shot!',
|
||||
category: 'special',
|
||||
tier: 'platinum',
|
||||
iconName: 'Rocket',
|
||||
},
|
||||
{
|
||||
id: 'ach_hole_in_one',
|
||||
title: 'Ace in the Hole',
|
||||
description: 'Record a Hole-in-One (Score of 1 on any hole)!',
|
||||
category: 'special',
|
||||
tier: 'platinum',
|
||||
iconName: 'Star',
|
||||
},
|
||||
{
|
||||
id: 'ach_albatross',
|
||||
title: 'The Elusive Albatross',
|
||||
description: 'Card a Double Eagle / Albatross (3 strokes under par on a single hole).',
|
||||
category: 'special',
|
||||
tier: 'platinum',
|
||||
iconName: 'Gem',
|
||||
},
|
||||
];
|
||||
|
||||
export interface EvaluatedAchievementResult {
|
||||
unlockedList: Achievement[];
|
||||
newlyUnlocked: Achievement[];
|
||||
totalUnlockedCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates all played rounds to determine which achievements are unlocked.
|
||||
*/
|
||||
export function evaluateAchievements(
|
||||
rounds: Round[],
|
||||
courses: Course[],
|
||||
previouslyUnlockedIds: string[] = []
|
||||
): EvaluatedAchievementResult {
|
||||
const unlockedMap = new Map<string, { roundName: string; date: string }>();
|
||||
|
||||
rounds.forEach((round) => {
|
||||
const course = courses.find((c) => c.id === round.courseId) || courses[0];
|
||||
if (!course) return;
|
||||
|
||||
const stats = calculateRoundStats(round, course);
|
||||
const isFull18 = round.holesPlayed === 18 && stats.holesPlayedCount >= 18;
|
||||
const isCompleted = stats.holesPlayedCount >= (round.holesPlayed || 9);
|
||||
|
||||
// 1. First Swing
|
||||
if (stats.holesPlayedCount >= 9) {
|
||||
if (!unlockedMap.has('ach_first_round')) {
|
||||
unlockedMap.set('ach_first_round', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Break 100
|
||||
if (isFull18 && stats.totalGross > 0 && stats.totalGross < 100) {
|
||||
if (!unlockedMap.has('ach_break_100')) {
|
||||
unlockedMap.set('ach_break_100', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Sub-90 Club
|
||||
if (isFull18 && stats.totalGross > 0 && stats.totalGross < 90) {
|
||||
if (!unlockedMap.has('ach_sub_90')) {
|
||||
unlockedMap.set('ach_sub_90', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Breaking 80
|
||||
if (isFull18 && stats.totalGross > 0 && stats.totalGross < 80) {
|
||||
if (!unlockedMap.has('ach_break_80')) {
|
||||
unlockedMap.set('ach_break_80', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Scratch Golfer
|
||||
if (isFull18 && stats.totalGross > 0 && stats.toPar <= 0) {
|
||||
if (!unlockedMap.has('ach_scratch_golfer')) {
|
||||
unlockedMap.set('ach_scratch_golfer', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Fairway Finder (50%+)
|
||||
if (stats.firEligible >= 5 && stats.firPercentage >= 50) {
|
||||
if (!unlockedMap.has('ach_fairway_finder')) {
|
||||
unlockedMap.set('ach_fairway_finder', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Fairway Dominance (70%+)
|
||||
if (isFull18 && stats.firEligible >= 10 && stats.firPercentage >= 70) {
|
||||
if (!unlockedMap.has('ach_fairway_dominance')) {
|
||||
unlockedMap.set('ach_fairway_dominance', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Fairway Sniper (80%+)
|
||||
if (isFull18 && stats.firEligible >= 10 && stats.firPercentage >= 80) {
|
||||
if (!unlockedMap.has('ach_fairway_sniper')) {
|
||||
unlockedMap.set('ach_fairway_sniper', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Target Locked (GIR 9+ / 50%+)
|
||||
if (isFull18 && stats.girHit >= 9) {
|
||||
if (!unlockedMap.has('ach_gir_target_locked')) {
|
||||
unlockedMap.set('ach_gir_target_locked', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 10. Birdie Hunter
|
||||
if (stats.birdieCount >= 1) {
|
||||
if (!unlockedMap.has('ach_first_birdie')) {
|
||||
unlockedMap.set('ach_first_birdie', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Eagle Eye
|
||||
if (stats.eagleCount >= 1) {
|
||||
if (!unlockedMap.has('ach_eagle_eye')) {
|
||||
unlockedMap.set('ach_eagle_eye', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Albatross
|
||||
if (stats.albatrossCount >= 1) {
|
||||
if (!unlockedMap.has('ach_albatross')) {
|
||||
unlockedMap.set('ach_albatross', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 13. Clean Sheet (0 penalties)
|
||||
if (isFull18 && stats.totalPenaltyStrokes === 0) {
|
||||
if (!unlockedMap.has('ach_clean_sheet')) {
|
||||
unlockedMap.set('ach_clean_sheet', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Flatstick Pure (0 three putts)
|
||||
if (isFull18 && stats.threePuttPlusCount === 0 && stats.totalPutts > 0) {
|
||||
if (!unlockedMap.has('ach_no_three_putts')) {
|
||||
unlockedMap.set('ach_no_three_putts', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 15. Clutch Sand Save
|
||||
if (stats.sandSavesConverted >= 1) {
|
||||
if (!unlockedMap.has('ach_clutch_sand_save')) {
|
||||
unlockedMap.set('ach_clutch_sand_save', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 16. Sand Wizard (100% with min 2)
|
||||
if (stats.sandSaveOpportunities >= 2 && stats.sandSavePercentage === 100) {
|
||||
if (!unlockedMap.has('ach_sand_wizard')) {
|
||||
unlockedMap.set('ach_sand_wizard', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 17. Up & Down Specialist (3+ scrambles)
|
||||
if (stats.scramblesConverted >= 3) {
|
||||
if (!unlockedMap.has('ach_scramble_specialist')) {
|
||||
unlockedMap.set('ach_scramble_specialist', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// 18. Beach Escape (hit bunker and finished hole)
|
||||
if (stats.totalBunkerHoles >= 1) {
|
||||
if (!unlockedMap.has('ach_out_of_sand')) {
|
||||
unlockedMap.set('ach_out_of_sand', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
// Hole-by-Hole checks
|
||||
let lastHoleDiff: number | null = null;
|
||||
let consecutiveBirdies = 0;
|
||||
|
||||
course.holes.forEach((hole) => {
|
||||
const s = round.scores[hole.holeNumber];
|
||||
if (!s || !s.grossScore) return;
|
||||
|
||||
const gross = s.grossScore;
|
||||
const par = hole.par;
|
||||
const diff = gross - par;
|
||||
|
||||
// Hole in One!
|
||||
if (gross === 1) {
|
||||
if (!unlockedMap.has('ach_hole_in_one')) {
|
||||
unlockedMap.set('ach_hole_in_one', {
|
||||
roundName: `${round.courseName} (Hole ${hole.holeNumber})`,
|
||||
date: round.date,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Drove the green on a Par 4!
|
||||
// On Par 4: GIR hit and (gross - putts === 1, or drive reached green)
|
||||
if (par === 4 && s.gir === 'hit' && (gross - (s.putts || 0) === 1 || (s.putts && gross === s.putts + 1))) {
|
||||
if (!unlockedMap.has('ach_drove_the_green')) {
|
||||
unlockedMap.set('ach_drove_the_green', {
|
||||
roundName: `${round.courseName} (Hole ${hole.holeNumber})`,
|
||||
date: round.date,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fringe Whisperer: Foregreen putt with par or birdie
|
||||
if (s.foregreenPutts && s.foregreenPutts > 0 && gross <= par) {
|
||||
if (!unlockedMap.has('ach_fringe_whisperer')) {
|
||||
unlockedMap.set('ach_fringe_whisperer', {
|
||||
roundName: `${round.courseName} (Hole ${hole.holeNumber})`,
|
||||
date: round.date,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Birdie streak
|
||||
if (diff === -1) {
|
||||
consecutiveBirdies++;
|
||||
if (consecutiveBirdies >= 2) {
|
||||
if (!unlockedMap.has('ach_birdie_streak')) {
|
||||
unlockedMap.set('ach_birdie_streak', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecutiveBirdies = 0;
|
||||
}
|
||||
|
||||
// Bounce Back: Birdie right after bogey or worse
|
||||
if (diff <= -1 && lastHoleDiff !== null && lastHoleDiff >= 1) {
|
||||
if (!unlockedMap.has('ach_bounce_back')) {
|
||||
unlockedMap.set('ach_bounce_back', { roundName: round.courseName, date: round.date });
|
||||
}
|
||||
}
|
||||
|
||||
lastHoleDiff = diff;
|
||||
});
|
||||
});
|
||||
|
||||
const newlyUnlocked: Achievement[] = [];
|
||||
|
||||
const unlockedList = ALL_ACHIEVEMENTS.map((ach) => {
|
||||
const unlockInfo = unlockedMap.get(ach.id);
|
||||
const isUnlocked = !!unlockInfo;
|
||||
const isNew = isUnlocked && !previouslyUnlockedIds.includes(ach.id);
|
||||
|
||||
const updated: Achievement = {
|
||||
...ach,
|
||||
isUnlocked,
|
||||
unlockedAt: unlockInfo?.date,
|
||||
unlockedRoundName: unlockInfo?.roundName,
|
||||
};
|
||||
|
||||
if (isNew) {
|
||||
newlyUnlocked.push(updated);
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
|
||||
return {
|
||||
unlockedList,
|
||||
newlyUnlocked,
|
||||
totalUnlockedCount: unlockedMap.size,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import { Hole, HoleCoordinates, LatLngLiteral } from '../types/golf';
|
||||
|
||||
const EARTH_RADIUS_METERS = 6371000;
|
||||
|
||||
/**
|
||||
* Calculates distance between two LatLng points in meters using Haversine formula.
|
||||
*/
|
||||
export function calculateDistanceMeters(from: LatLngLiteral, to: LatLngLiteral): number {
|
||||
const dLat = ((to.lat - from.lat) * Math.PI) / 180;
|
||||
const dLng = ((to.lng - from.lng) * Math.PI) / 180;
|
||||
const lat1 = (from.lat * Math.PI) / 180;
|
||||
const lat2 = (to.lat * Math.PI) / 180;
|
||||
|
||||
const a =
|
||||
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.sin(dLng / 2) * Math.sin(dLng / 2) * Math.cos(lat1) * Math.cos(lat2);
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
|
||||
return Math.round(EARTH_RADIUS_METERS * c);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates bearing in degrees (0 = North, 90 = East, 180 = South, 270 = West)
|
||||
*/
|
||||
export function calculateBearing(from: LatLngLiteral, to: LatLngLiteral): number {
|
||||
const lat1 = (from.lat * Math.PI) / 180;
|
||||
const lat2 = (to.lat * Math.PI) / 180;
|
||||
const dLng = ((to.lng - from.lng) * Math.PI) / 180;
|
||||
|
||||
const y = Math.sin(dLng) * Math.cos(lat2);
|
||||
const x =
|
||||
Math.cos(lat1) * Math.sin(lat2) - Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLng);
|
||||
const brng = (Math.atan2(y, x) * 180) / Math.PI;
|
||||
|
||||
return (brng + 360) % 360;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates destination point given starting point, distance in meters, and bearing in degrees.
|
||||
*/
|
||||
export function calculateDestinationPoint(
|
||||
start: LatLngLiteral,
|
||||
distanceMeters: number,
|
||||
bearingDegrees: number
|
||||
): LatLngLiteral {
|
||||
const δ = distanceMeters / EARTH_RADIUS_METERS;
|
||||
const θ = (bearingDegrees * Math.PI) / 180;
|
||||
const φ1 = (start.lat * Math.PI) / 180;
|
||||
const λ1 = (start.lng * Math.PI) / 180;
|
||||
|
||||
const φ2 = Math.asin(
|
||||
Math.sin(φ1) * Math.cos(δ) + Math.cos(φ1) * Math.sin(δ) * Math.cos(θ)
|
||||
);
|
||||
const λ2 =
|
||||
λ1 +
|
||||
Math.atan2(
|
||||
Math.sin(θ) * Math.sin(δ) * Math.cos(φ1),
|
||||
Math.cos(δ) - Math.sin(φ1) * Math.sin(φ2)
|
||||
);
|
||||
|
||||
return {
|
||||
lat: (φ2 * 180) / Math.PI,
|
||||
lng: (λ2 * 180) / Math.PI,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates realistic coordinates for a hole given a tee origin and target distance.
|
||||
*/
|
||||
export function generateHoleCoordinates(
|
||||
tee: LatLngLiteral,
|
||||
distanceMeters: number,
|
||||
bearingDegrees: number,
|
||||
greenDepthMeters: number = 28
|
||||
): HoleCoordinates {
|
||||
const greenCenter = calculateDestinationPoint(tee, distanceMeters, bearingDegrees);
|
||||
const greenFront = calculateDestinationPoint(
|
||||
greenCenter,
|
||||
greenDepthMeters / 2,
|
||||
(bearingDegrees + 180) % 360 // towards tee
|
||||
);
|
||||
const greenBack = calculateDestinationPoint(
|
||||
greenCenter,
|
||||
greenDepthMeters / 2,
|
||||
bearingDegrees // away from tee
|
||||
);
|
||||
const fairway = calculateDestinationPoint(tee, distanceMeters * 0.6, bearingDegrees);
|
||||
|
||||
return {
|
||||
tee,
|
||||
fairway,
|
||||
greenFront,
|
||||
greenCenter,
|
||||
greenBack,
|
||||
};
|
||||
}
|
||||
|
||||
export interface GreenDistancesResult {
|
||||
front: number; // in unit
|
||||
center: number;
|
||||
back: number;
|
||||
unit: 'meters' | 'yards';
|
||||
bearingToCenter: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Computes live GPS distances from player to green front, center, and back.
|
||||
*/
|
||||
export function getLiveGreenDistances(
|
||||
userPosition: LatLngLiteral,
|
||||
hole: Hole,
|
||||
unit: 'meters' | 'yards' = 'meters'
|
||||
): GreenDistancesResult | null {
|
||||
if (!hole.coordinates) return null;
|
||||
|
||||
const mFront = calculateDistanceMeters(userPosition, hole.coordinates.greenFront);
|
||||
const mCenter = calculateDistanceMeters(userPosition, hole.coordinates.greenCenter);
|
||||
const mBack = calculateDistanceMeters(userPosition, hole.coordinates.greenBack);
|
||||
const bearing = calculateBearing(userPosition, hole.coordinates.greenCenter);
|
||||
|
||||
if (unit === 'yards') {
|
||||
return {
|
||||
front: Math.round(mFront * 1.09361),
|
||||
center: Math.round(mCenter * 1.09361),
|
||||
back: Math.round(mBack * 1.09361),
|
||||
unit: 'yards',
|
||||
bearingToCenter: Math.round(bearing),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
front: mFront,
|
||||
center: mCenter,
|
||||
back: mBack,
|
||||
unit: 'meters',
|
||||
bearingToCenter: Math.round(bearing),
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetectedHoleResult {
|
||||
holeNumber: number;
|
||||
distanceToTee: number;
|
||||
distanceToGreen: number;
|
||||
isInsideFairwayCorridor: boolean;
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-detects which hole the player is currently on based on active GPS position.
|
||||
*/
|
||||
export function detectCurrentHoleFromGps(
|
||||
userPosition: LatLngLiteral,
|
||||
holes: Hole[]
|
||||
): DetectedHoleResult | null {
|
||||
const validHoles = holes.filter((h) => !!h.coordinates);
|
||||
if (validHoles.length === 0) return null;
|
||||
|
||||
let bestHole: Hole | null = null;
|
||||
let minScore = Infinity;
|
||||
let bestDistTee = 0;
|
||||
let bestDistGreen = 0;
|
||||
let isCorridor = false;
|
||||
|
||||
validHoles.forEach((h) => {
|
||||
const coords = h.coordinates!;
|
||||
const distTee = calculateDistanceMeters(userPosition, coords.tee);
|
||||
const distGreen = calculateDistanceMeters(userPosition, coords.greenCenter);
|
||||
const holeLength = calculateDistanceMeters(coords.tee, coords.greenCenter);
|
||||
|
||||
// If player is close to tee (within 50 meters), very high confidence!
|
||||
if (distTee <= 50 && distTee < minScore) {
|
||||
minScore = distTee;
|
||||
bestHole = h;
|
||||
bestDistTee = distTee;
|
||||
bestDistGreen = distGreen;
|
||||
isCorridor = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Corridor check: sum of distance from tee + distance to green shouldn't exceed holeLength + 60m
|
||||
const corridorSlack = distTee + distGreen - holeLength;
|
||||
if (corridorSlack < 70 && distGreen < holeLength + 30) {
|
||||
const score = corridorSlack + distGreen * 0.2;
|
||||
if (score < minScore) {
|
||||
minScore = score;
|
||||
bestHole = h;
|
||||
bestDistTee = distTee;
|
||||
bestDistGreen = distGreen;
|
||||
isCorridor = true;
|
||||
}
|
||||
} else {
|
||||
// Fallback: nearest green or tee within 200m
|
||||
const nearestDist = Math.min(distTee, distGreen);
|
||||
if (nearestDist < 200 && nearestDist + 150 < minScore) {
|
||||
minScore = nearestDist + 150;
|
||||
bestHole = h;
|
||||
bestDistTee = distTee;
|
||||
bestDistGreen = distGreen;
|
||||
isCorridor = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!bestHole) return null;
|
||||
|
||||
let confidence: 'high' | 'medium' | 'low' = 'low';
|
||||
if (bestDistTee <= 40 || bestDistGreen <= 35) confidence = 'high';
|
||||
else if (isCorridor) confidence = 'medium';
|
||||
|
||||
return {
|
||||
holeNumber: (bestHole as Hole).holeNumber,
|
||||
distanceToTee: bestDistTee,
|
||||
distanceToGreen: bestDistGreen,
|
||||
isInsideFairwayCorridor: isCorridor,
|
||||
confidence,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
import { Course, HoleScore, PenaltyType, Round } from '../types/golf';
|
||||
|
||||
export interface RoundStatsSummary {
|
||||
totalGross: number;
|
||||
totalPar: number;
|
||||
toPar: number;
|
||||
toParDisplay: string;
|
||||
holesPlayedCount: number;
|
||||
|
||||
// Front / Back split
|
||||
frontGross: number;
|
||||
frontPar: number;
|
||||
frontToPar: number;
|
||||
backGross: number;
|
||||
backPar: number;
|
||||
backToPar: number;
|
||||
|
||||
// Scoring Distribution
|
||||
albatrossCount: number;
|
||||
eagleCount: number;
|
||||
birdieCount: number;
|
||||
parCount: number;
|
||||
bogeyCount: number;
|
||||
doubleBogeyCount: number;
|
||||
triplePlusCount: number;
|
||||
|
||||
// Scoring by Par
|
||||
par3HolesCount: number;
|
||||
par3AvgScore: number;
|
||||
par4HolesCount: number;
|
||||
par4AvgScore: number;
|
||||
par5HolesCount: number;
|
||||
par5AvgScore: number;
|
||||
|
||||
// Fairway stats
|
||||
firEligible: number;
|
||||
firHit: number;
|
||||
firLeft: number;
|
||||
firRight: number;
|
||||
firShort: number;
|
||||
firPercentage: number;
|
||||
|
||||
// Green stats
|
||||
girEligible: number;
|
||||
girHit: number;
|
||||
girMissShort: number;
|
||||
girMissLong: number;
|
||||
girMissLeft: number;
|
||||
girMissRight: number;
|
||||
girPercentage: number;
|
||||
|
||||
// Putting stats
|
||||
totalPutts: number;
|
||||
avgPuttsPerHole: number;
|
||||
onePuttCount: number;
|
||||
twoPuttCount: number;
|
||||
threePuttPlusCount: number;
|
||||
puttsPerGIR: number; // Putts on holes where GIR was hit
|
||||
|
||||
// Foregreen / Off-green stats
|
||||
totalForegreenPutts: number;
|
||||
holesWithForegreenPutt: number;
|
||||
firstPuttFromFringeCount: number;
|
||||
|
||||
// Bunker stats
|
||||
totalBunkerHoles: number;
|
||||
fairwayBunkersHit: number;
|
||||
greensideBunkersHit: number;
|
||||
bothBunkersHit: number;
|
||||
sandSaveOpportunities: number;
|
||||
sandSavesConverted: number;
|
||||
sandSavePercentage: number;
|
||||
|
||||
// Penalty stats
|
||||
totalPenaltyStrokes: number;
|
||||
holesWithPenalty: number;
|
||||
penaltiesByType: Record<PenaltyType, number>;
|
||||
|
||||
// Scrambling / Up & Down
|
||||
scrambleOpportunities: number; // Missed GIR
|
||||
scramblesConverted: number; // Made par or better
|
||||
scramblePercentage: number;
|
||||
}
|
||||
|
||||
export function getScoreName(strokes: number, par: number): string {
|
||||
const diff = strokes - par;
|
||||
if (strokes === 1) return 'Hole-in-One';
|
||||
if (diff <= -3) return 'Albatross';
|
||||
if (diff === -2) return 'Eagle';
|
||||
if (diff === -1) return 'Birdie';
|
||||
if (diff === 0) return 'Par';
|
||||
if (diff === 1) return 'Bogey';
|
||||
if (diff === 2) return 'Double Bogey';
|
||||
if (diff === 3) return 'Triple Bogey';
|
||||
return `+${diff}`;
|
||||
}
|
||||
|
||||
export function formatToPar(diff: number): string {
|
||||
if (diff === 0) return 'E';
|
||||
if (diff > 0) return `+${diff}`;
|
||||
return `${diff}`;
|
||||
}
|
||||
|
||||
export function calculateRoundStats(round: Round, course: Course): RoundStatsSummary {
|
||||
let totalGross = 0;
|
||||
let totalPar = 0;
|
||||
let frontGross = 0;
|
||||
let frontPar = 0;
|
||||
let backGross = 0;
|
||||
let backPar = 0;
|
||||
let holesPlayedCount = 0;
|
||||
|
||||
let albatrossCount = 0;
|
||||
let eagleCount = 0;
|
||||
let birdieCount = 0;
|
||||
let parCount = 0;
|
||||
let bogeyCount = 0;
|
||||
let doubleBogeyCount = 0;
|
||||
let triplePlusCount = 0;
|
||||
|
||||
let par3Total = 0;
|
||||
let par3Count = 0;
|
||||
let par4Total = 0;
|
||||
let par4Count = 0;
|
||||
let par5Total = 0;
|
||||
let par5Count = 0;
|
||||
|
||||
let firEligible = 0;
|
||||
let firHit = 0;
|
||||
let firLeft = 0;
|
||||
let firRight = 0;
|
||||
let firShort = 0;
|
||||
|
||||
let girEligible = 0;
|
||||
let girHit = 0;
|
||||
let girMissShort = 0;
|
||||
let girMissLong = 0;
|
||||
let girMissLeft = 0;
|
||||
let girMissRight = 0;
|
||||
|
||||
let totalPutts = 0;
|
||||
let puttsRecordedHoles = 0;
|
||||
let onePuttCount = 0;
|
||||
let twoPuttCount = 0;
|
||||
let threePuttPlusCount = 0;
|
||||
let puttsOnGIRTotal = 0;
|
||||
let girHolesWithPutts = 0;
|
||||
|
||||
let totalForegreenPutts = 0;
|
||||
let holesWithForegreenPutt = 0;
|
||||
let firstPuttFromFringeCount = 0;
|
||||
|
||||
let totalBunkerHoles = 0;
|
||||
let fairwayBunkersHit = 0;
|
||||
let greensideBunkersHit = 0;
|
||||
let bothBunkersHit = 0;
|
||||
let sandSaveOpportunities = 0;
|
||||
let sandSavesConverted = 0;
|
||||
|
||||
let totalPenaltyStrokes = 0;
|
||||
let holesWithPenalty = 0;
|
||||
const penaltiesByType: Record<PenaltyType, number> = {
|
||||
water: 0,
|
||||
ob: 0,
|
||||
lost_ball: 0,
|
||||
unplayable: 0,
|
||||
other: 0,
|
||||
};
|
||||
|
||||
let scrambleOpportunities = 0;
|
||||
let scramblesConverted = 0;
|
||||
|
||||
course.holes.forEach((hole) => {
|
||||
const score = round.scores[hole.holeNumber];
|
||||
if (!score || score.grossScore === null || score.grossScore === undefined || score.grossScore <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
holesPlayedCount++;
|
||||
const gross = score.grossScore;
|
||||
const par = hole.par;
|
||||
const diff = gross - par;
|
||||
|
||||
totalGross += gross;
|
||||
totalPar += par;
|
||||
|
||||
if (hole.holeNumber <= 9) {
|
||||
frontGross += gross;
|
||||
frontPar += par;
|
||||
} else {
|
||||
backGross += gross;
|
||||
backPar += par;
|
||||
}
|
||||
|
||||
// Score distribution
|
||||
if (diff <= -3) albatrossCount++;
|
||||
else if (diff === -2) eagleCount++;
|
||||
else if (diff === -1) birdieCount++;
|
||||
else if (diff === 0) parCount++;
|
||||
else if (diff === 1) bogeyCount++;
|
||||
else if (diff === 2) doubleBogeyCount++;
|
||||
else triplePlusCount++;
|
||||
|
||||
// Scoring by Par
|
||||
if (par === 3) {
|
||||
par3Total += gross;
|
||||
par3Count++;
|
||||
} else if (par === 4) {
|
||||
par4Total += gross;
|
||||
par4Count++;
|
||||
} else if (par === 5) {
|
||||
par5Total += gross;
|
||||
par5Count++;
|
||||
}
|
||||
|
||||
// Fairways (only par 4 and par 5)
|
||||
if (par > 3 && score.fir && score.fir !== 'na') {
|
||||
firEligible++;
|
||||
if (score.fir === 'hit') firHit++;
|
||||
else if (score.fir === 'left') firLeft++;
|
||||
else if (score.fir === 'right') firRight++;
|
||||
else if (score.fir === 'short') firShort++;
|
||||
}
|
||||
|
||||
// Greens in Regulation
|
||||
if (score.gir) {
|
||||
girEligible++;
|
||||
if (score.gir === 'hit') {
|
||||
girHit++;
|
||||
if (score.putts !== null && score.putts !== undefined) {
|
||||
puttsOnGIRTotal += score.putts;
|
||||
girHolesWithPutts++;
|
||||
}
|
||||
} else {
|
||||
if (score.gir === 'miss_short') girMissShort++;
|
||||
else if (score.gir === 'miss_long') girMissLong++;
|
||||
else if (score.gir === 'miss_left') girMissLeft++;
|
||||
else if (score.gir === 'miss_right') girMissRight++;
|
||||
|
||||
// Scramble / Up & Down opportunity
|
||||
scrambleOpportunities++;
|
||||
if (gross <= par) {
|
||||
scramblesConverted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Putts
|
||||
if (score.putts !== null && score.putts !== undefined) {
|
||||
totalPutts += score.putts;
|
||||
puttsRecordedHoles++;
|
||||
if (score.putts === 1) onePuttCount++;
|
||||
else if (score.putts === 2) twoPuttCount++;
|
||||
else if (score.putts >= 3) threePuttPlusCount++;
|
||||
}
|
||||
|
||||
// Foregreen / Off-green putts
|
||||
if (score.foregreenPutts && score.foregreenPutts > 0) {
|
||||
totalForegreenPutts += score.foregreenPutts;
|
||||
holesWithForegreenPutt++;
|
||||
}
|
||||
if (score.firstPuttFromFringe) {
|
||||
firstPuttFromFringeCount++;
|
||||
}
|
||||
|
||||
// Bunkers
|
||||
if (score.bunker && score.bunker.hit) {
|
||||
totalBunkerHoles++;
|
||||
const isFairway = score.bunker.fairwayBunker;
|
||||
const isGreenside = score.bunker.greensideBunker;
|
||||
if (isFairway && isGreenside) bothBunkersHit++;
|
||||
else if (isFairway) fairwayBunkersHit++;
|
||||
else if (isGreenside) greensideBunkersHit++;
|
||||
|
||||
if (isGreenside) {
|
||||
sandSaveOpportunities++;
|
||||
// If explicitly set or if score <= par
|
||||
if (score.bunker.sandSave === true || (score.bunker.sandSave !== false && gross <= par)) {
|
||||
sandSavesConverted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Penalties
|
||||
if (score.penalties && score.penalties.length > 0) {
|
||||
let penaltyStrokesOnHole = 0;
|
||||
score.penalties.forEach((pen) => {
|
||||
penaltyStrokesOnHole += pen.strokes;
|
||||
penaltiesByType[pen.type] = (penaltiesByType[pen.type] || 0) + pen.strokes;
|
||||
});
|
||||
if (penaltyStrokesOnHole > 0) {
|
||||
holesWithPenalty++;
|
||||
totalPenaltyStrokes += penaltyStrokesOnHole;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const toPar = totalGross - totalPar;
|
||||
const toParDisplay = holesPlayedCount > 0 ? formatToPar(toPar) : 'E';
|
||||
|
||||
const firPercentage = firEligible > 0 ? Math.round((firHit / firEligible) * 100) : 0;
|
||||
const girPercentage = girEligible > 0 ? Math.round((girHit / girEligible) * 100) : 0;
|
||||
const avgPuttsPerHole = puttsRecordedHoles > 0 ? Number((totalPutts / puttsRecordedHoles).toFixed(1)) : 0;
|
||||
const puttsPerGIR = girHolesWithPutts > 0 ? Number((puttsOnGIRTotal / girHolesWithPutts).toFixed(2)) : 0;
|
||||
const sandSavePercentage = sandSaveOpportunities > 0 ? Math.round((sandSavesConverted / sandSaveOpportunities) * 100) : 0;
|
||||
const scramblePercentage = scrambleOpportunities > 0 ? Math.round((scramblesConverted / scrambleOpportunities) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalGross,
|
||||
totalPar,
|
||||
toPar,
|
||||
toParDisplay,
|
||||
holesPlayedCount,
|
||||
|
||||
frontGross,
|
||||
frontPar,
|
||||
frontToPar: frontGross - frontPar,
|
||||
backGross,
|
||||
backPar,
|
||||
backToPar: backGross - backPar,
|
||||
|
||||
albatrossCount,
|
||||
eagleCount,
|
||||
birdieCount,
|
||||
parCount,
|
||||
bogeyCount,
|
||||
doubleBogeyCount,
|
||||
triplePlusCount,
|
||||
|
||||
par3HolesCount: par3Count,
|
||||
par3AvgScore: par3Count > 0 ? Number((par3Total / par3Count).toFixed(2)) : 0,
|
||||
par4HolesCount: par4Count,
|
||||
par4AvgScore: par4Count > 0 ? Number((par4Total / par4Count).toFixed(2)) : 0,
|
||||
par5HolesCount: par5Count,
|
||||
par5AvgScore: par5Count > 0 ? Number((par5Total / par5Count).toFixed(2)) : 0,
|
||||
|
||||
firEligible,
|
||||
firHit,
|
||||
firLeft,
|
||||
firRight,
|
||||
firShort,
|
||||
firPercentage,
|
||||
|
||||
girEligible,
|
||||
girHit,
|
||||
girMissShort,
|
||||
girMissLong,
|
||||
girMissLeft,
|
||||
girMissRight,
|
||||
girPercentage,
|
||||
|
||||
totalPutts,
|
||||
avgPuttsPerHole,
|
||||
onePuttCount,
|
||||
twoPuttCount,
|
||||
threePuttPlusCount,
|
||||
puttsPerGIR,
|
||||
|
||||
totalForegreenPutts,
|
||||
holesWithForegreenPutt,
|
||||
firstPuttFromFringeCount,
|
||||
|
||||
totalBunkerHoles,
|
||||
fairwayBunkersHit,
|
||||
greensideBunkersHit,
|
||||
bothBunkersHit,
|
||||
sandSaveOpportunities,
|
||||
sandSavesConverted,
|
||||
sandSavePercentage,
|
||||
|
||||
totalPenaltyStrokes,
|
||||
holesWithPenalty,
|
||||
penaltiesByType,
|
||||
|
||||
scrambleOpportunities,
|
||||
scramblesConverted,
|
||||
scramblePercentage,
|
||||
};
|
||||
}
|
||||
|
||||
export function createEmptyHoleScore(holeNumber: number, par: number): HoleScore {
|
||||
return {
|
||||
holeNumber,
|
||||
grossScore: par,
|
||||
fir: par === 3 ? 'na' : 'hit',
|
||||
gir: 'hit',
|
||||
putts: 2,
|
||||
foregreenPutts: 0,
|
||||
firstPuttFromFringe: false,
|
||||
firstPuttDistanceFt: null,
|
||||
penalties: [],
|
||||
bunker: {
|
||||
hit: false,
|
||||
fairwayBunker: false,
|
||||
greensideBunker: false,
|
||||
sandSave: null,
|
||||
},
|
||||
upAndDown: null,
|
||||
driveClub: par === 3 ? 'Iron' : 'Driver',
|
||||
driveDistanceYds: null,
|
||||
notes: '',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modify—file watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user