diff --git a/.claude/settings.local.json b/.claude/settings.local.json
new file mode 100644
index 0000000..aba839c
--- /dev/null
+++ b/.claude/settings.local.json
@@ -0,0 +1,5 @@
+{
+ "permissions": {
+ "defaultMode": "acceptEdits"
+ }
+}
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..33f23d7
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,38 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## Documentation
+
+For detailed information about this project, refer to the documentation files in `/docs/`:
+
+- **[Project Overview](/docs/project-overview.md)** - Project summary, key characteristics, and game mechanics
+- **[Features](/docs/features.md)** - Detailed game features, scoring system, and security measures
+- **[Tech Stack](/docs/tech-stack.md)** - Complete technology stack and dependencies
+- **[Development Guidelines](/docs/development.md)** - Development commands, coding standards, and best practices
+- **[Game Flow](/docs/game-flow.md)** - Complete gameplay flow from start to finish
+- **[Project Structure](/docs/project-structure.md)** - Directory organization and file purposes
+
+## Quick Reference
+
+**Development Commands:**
+- `npm run dev` - Start development server with Turbopack
+- `npm run build` - Build the application for production
+- `npm start` - Start production server
+- `npm run lint` - Run ESLint
+
+**Key Guidelines:**
+- **JavaScript Only**: No TypeScript files (.ts, .tsx)
+- **Function Parameters**: Use individual parameters, not object destructuring
+- **File Modifications**: Only modify source code, `/docs`, and `/plans`
+- **Security**: Never expose secrets or keys
+- **Testing**: Inform user when complete - they handle manual testing
+
+## Critical Instructions
+
+When working on this codebase, always:
+1. Read the relevant documentation files in `/docs/` for context
+2. Follow the JavaScript-only approach with individual function parameters
+3. Maintain server-side security for game sessions and coordinates
+4. Use existing patterns and libraries already present in the codebase
+5. Highlight any configuration file changes for manual processing
\ No newline at end of file
diff --git a/README.md b/README.md
index 66bb426..920dc35 100644
--- a/README.md
+++ b/README.md
@@ -1,36 +1,80 @@
-This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
+# VNGeoGuessr
-## Getting Started
+A GeoGuessr clone focused on Vietnamese locations with accurate boundary detection and anti-cheat security. Play geolocation guessing games across 5 Vietnamese locations using real street-level imagery.
-First, run the development server:
+## 🎮 Features
+
+- **5 Vietnamese Locations**: Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
+- **360° Street View**: Mapillary panoramic images with PhotoSphere viewer
+- **Anti-Cheat Security**: Server-side session management prevents cheating
+- **Distance-Based Scoring**: 0-5 point system based on accuracy
+- **Persistent Leaderboards**: Redis-powered top 200 player rankings
+- **Accurate Boundaries**: Nominatim API + Turf.js for precise city detection
+
+## 📚 Documentation
+
+For detailed information about this project, see the documentation in `/docs/`:
+
+- **[Project Overview](docs/project-overview.md)** - Project summary and key characteristics
+- **[Features](docs/features.md)** - Complete feature list and game mechanics
+- **[Tech Stack](docs/tech-stack.md)** - Technology stack and dependencies
+- **[Development Guidelines](docs/development.md)** - Development setup and coding standards
+- **[Game Flow](docs/game-flow.md)** - Complete gameplay flow documentation
+- **[Project Structure](docs/project-structure.md)** - Codebase organization and architecture
+
+## 🚀 Getting Started
+
+### Prerequisites
+- Node.js 18+
+- npm or yarn
+- Redis server (for leaderboards)
+
+### Development
```bash
+# Install dependencies
+npm install
+
+# Start development server
npm run dev
-# or
-yarn dev
-# or
-pnpm dev
-# or
-bun dev
+
+# Build for production
+npm run build
+
+# Start production server
+npm start
+
+# Run linting
+npm run lint
```
-Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
+Open [http://localhost:3000](http://localhost:3000) to play the game.
-You can start editing the page by modifying `app/page.js`. The page auto-updates as you edit the file.
+## 🏗️ Built With
-This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
+- **Frontend**: Next.js 15 + React 19 + Tailwind CSS 4
+- **Street View**: Mapillary API + PhotoSphere Viewer
+- **Maps**: Leaflet + OpenStreetMap
+- **Geographic Data**: Nominatim API + Turf.js
+- **Storage**: Redis (leaderboards) + In-memory sessions
+- **UI Components**: shadcn/ui
-## Learn More
+## 🎯 How to Play
-To learn more about Next.js, take a look at the following resources:
+1. Enter your username
+2. Select a Vietnamese location
+3. View the 360° street panorama
+4. Place your guess on the map
+5. Earn points based on accuracy (closer = more points)
+6. Compete for the top 200 leaderboard spots!
-- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
-- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
+## 📋 Development Notes
-You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
+- **JavaScript Only**: No TypeScript files
+- **Individual Parameters**: Functions use separate parameters, not object destructuring
+- **Server-Side Security**: Game coordinates never sent to client
+- **Session Management**: Automatic cleanup after 30 minutes
-## Deploy on Vercel
+## 🤝 Contributing
-The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
-
-Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
+This project follows specific development guidelines. Please read the [Development Guidelines](docs/development.md) before contributing.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..a23922a
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,52 @@
+# Development Guidelines
+
+## Development Commands
+
+- `npm run dev` - Start development server with Turbopack
+- `npm run build` - Build the application for production
+- `npm start` - Start production server
+- `npm run lint` - Run ESLint
+
+## Important Development Guidelines
+
+### JavaScript Only
+- This project uses **JavaScript exclusively**
+- Never create or suggest TypeScript files (.ts, .tsx)
+- All components and utilities should be .js or .jsx files
+
+### Function Parameters
+- All functions should use **individual parameters** instead of object destructuring
+- Use `function(param1, param2)` instead of `function({param1, param2})`
+- This applies to React components, utility functions, and API handlers
+
+### File Modification Policy
+- **Only modify source code files**, documentation (/docs), and plans (/plans)
+- Configuration changes (package.json, next.config.mjs, eslint.config.mjs, components.json, etc.) should be highlighted for manual processing
+- Environment files and build settings require manual review
+
+### Security Best Practices
+- Never expose or commit secret keys and sensitive information
+- Server-side session management prevents client-side coordinate access
+- All geographic calculations must be performed server-side
+- Session cleanup after 30 minutes for security
+
+### Testing & Completion
+- After completing implementation tasks, inform the user that work is complete
+- Ready for manual testing - do NOT attempt to run test commands
+- Do NOT start development servers - user handles testing manually
+
+## shadcn/ui Configuration
+
+- **Style**: "new-york"
+- **Path aliases**: Configured for `@/components`, `@/lib`, etc.
+- **Components**: Use JavaScript (.js) not TypeScript
+- **CSS variables**: Enabled for theming
+- **Components location**: `src/components/ui/`
+
+## Code Style Standards
+
+- Follow existing code patterns in the codebase
+- Use established libraries and utilities already present
+- Maintain consistent naming conventions
+- No comments unless explicitly requested
+- Prefer editing existing files over creating new ones
\ No newline at end of file
diff --git a/docs/features.md b/docs/features.md
new file mode 100644
index 0000000..f6ef975
--- /dev/null
+++ b/docs/features.md
@@ -0,0 +1,35 @@
+# Game Features
+
+## Location Coverage
+- **5 Vietnamese Locations**: Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
+- **Accurate Location Generation**: Nominatim API + Turf.js for precise city boundary detection
+
+## Street View System
+- **Mapillary Integration**: Panoramic street-level images
+- **3-Tier Search Logic**: Progressive radius fallback (1km → 2km → 5km)
+- **360° Panorama Viewer**: PhotoSphere Viewer with zoom and fullscreen controls
+
+## Interactive Maps
+- **Leaflet Integration**: OpenStreetMap-based interactive mapping
+- **Click-to-Place**: Intuitive guess marker placement
+
+## Anti-Cheat Security
+- **Server-side Session Management**: Target coordinates never sent to client
+- **Session Isolation**: Unique session IDs with automatic cleanup
+- **30-minute Expiry**: Automatic session cleanup for security
+- **Server-side Calculations**: All distance and scoring computed server-side
+
+## Scoring System
+Distance-based points (0-5 scale):
+- **0-50m**: 5 points
+- **50m-100m**: 4 points
+- **100m-200m**: 3 points
+- **200m-500m**: 2 points
+- **500m-1km**: 1 point
+- **1km+**: 0 points
+
+## Leaderboards
+- **Redis-based Storage**: Persistent leaderboard data
+- **Top 100 Players**: Maximum entry limit with auto-trimming
+- **Highest Score Only**: Username + best score tracking
+- **No Expiration**: Persistent storage across sessions
diff --git a/docs/game-flow.md b/docs/game-flow.md
new file mode 100644
index 0000000..250e03f
--- /dev/null
+++ b/docs/game-flow.md
@@ -0,0 +1,70 @@
+# Gameplay Flow
+
+## Complete Game Flow
+
+### 1. Username Setup
+- Check localStorage for existing username
+- Display UsernameModal if not set
+- Store username for leaderboard tracking
+
+### 2. Location Selection
+- Choose from 5 Vietnamese locations:
+ - Ha Noi
+ - Da Nang
+ - Ho Chi Minh
+ - Da Lat
+ - Duc Hoa (Long An)
+
+### 3. Session Creation
+- Server generates unique session ID using UUID
+- Server stores exact target location securely in session Map
+- Client receives session ID but never the target coordinates
+
+### 4. Location Display Process
+- **Boundary Detection**: Server uses Nominatim API + Turf.js for accurate city boundary detection
+- **Image Search**: Mapillary API with 3-tier fallback logic:
+ 1. Search within 1km radius
+ 2. Fallback to 2km radius if no results
+ 3. Final fallback to 5km radius
+- **Panorama Display**: PhotoSphereViewer renders 360° street view
+- **Security**: Client never receives exact target coordinates
+
+### 5. Guessing Phase
+- **Panorama Interaction**: View 360° street-level image with PhotoSphereViewer controls
+- **Map Interaction**: Place guess marker on interactive Leaflet map with OpenStreetMap
+- **Submission**: Click submit button to finalize guess
+
+### 6. Server-Side Processing
+- **Input**: Client submits guess coordinates + session ID only
+- **Retrieval**: Server retrieves exact location from secure session storage
+- **Calculation**: Server calculates distance using Turf.js (prevents client-side cheating)
+- **Scoring**: Server-side score calculation with distance-based points
+- **Cleanup**: Session destroyed after submission for security
+
+### 7. Scoring System
+Distance-based points (0-5 scale):
+- **0-50m**: 5 points
+- **50m-100m**: 4 points
+- **100m-200m**: 3 points
+- **200m-500m**: 2 points
+- **500m-1km**: 1 point
+- **1km+**: 0 points
+
+### 8. Results Display
+- Show calculated distance between guess and actual location
+- Display earned points based on accuracy
+- Reveal exact target coordinates and location details
+- Show current leaderboard rank (Vietnam-wide)
+- Compare guess vs actual location on map
+
+### 9. Leaderboard Management
+- **Redis Storage**: Persistent storage with no expiration
+- **Top 200 Limit**: Automatic trimming to maintain top performers only
+- **Score Updates**: Only update if new score is higher than existing
+- **Data Structure**: Username + highest score tracking
+
+### 10. Continue or Exit
+- Option to start next round with new session and location
+- Option to return to city selection for different city
+- Option to view full leaderboard
+- Session cleanup ensures fresh start for each round
\ No newline at end of file
diff --git a/docs/project-overview.md b/docs/project-overview.md
new file mode 100644
index 0000000..9b40f3d
--- /dev/null
+++ b/docs/project-overview.md
@@ -0,0 +1,19 @@
+# Project Overview
+
+VNGeoGuessr is a GeoGuessr clone focused on Vietnamese locations, supporting 6 major cities with accurate boundary detection and anti-cheat security. Built with Next.js 15.4.6, React 19, and Tailwind CSS 4.
+
+## Key Characteristics
+
+- **Location Coverage**: 5 Vietnamese locations - Ha Noi, Da Nang, Ho Chi Minh, Da Lat, Duc Hoa (Long An)
+- **Accurate Location Generation**: Nominatim API + Turf.js for precise city boundary detection
+- **Anti-Cheat Security**: Server-side session management, no client-side target coordinates
+- **Persistent Storage**: Redis-based leaderboards with top 200 players
+- **Interactive Gameplay**: Click-to-guess map interface
+
+## Game Mechanics
+
+- **Street View**: Mapillary panoramic images with 3-tier search fallback
+- **360° Viewer**: PhotoSphere Viewer with zoom and fullscreen controls
+- **Interactive Maps**: Leaflet + OpenStreetMap for guess placement
+- **Distance-based Scoring**: 0-5 point scale based on accuracy
+- **Session Isolation**: Server-side target coordinates for security
\ No newline at end of file
diff --git a/docs/project-structure.md b/docs/project-structure.md
new file mode 100644
index 0000000..71b16ff
--- /dev/null
+++ b/docs/project-structure.md
@@ -0,0 +1,81 @@
+# Project Structure
+
+## Root Directory
+- `CLAUDE.md` - Project instructions and guidelines for Claude Code
+- `components.json` - shadcn/ui configuration
+- `package.json` - Dependencies and scripts
+- `next.config.mjs` - Next.js configuration
+- `eslint.config.mjs` - ESLint configuration
+- `postcss.config.mjs` - PostCSS configuration
+- `jsconfig.json` - JavaScript project configuration
+
+## Source Code (`src/`)
+
+### App Router (`src/app/`)
+Next.js 15 App Router structure:
+
+#### Pages
+- `page.js` - Homepage with city selection
+- `layout.js` - Root layout component
+- `globals.css` - Global styles
+- `favicon.ico` - Site favicon
+
+#### Game Pages
+- `game/page.js` - Main game interface
+- `leaderboard/page.js` - Leaderboard display
+- `debug/page.js` - Development debugging tools
+
+#### API Routes (`src/app/api/`)
+- `new-game/route.js` - Creates new game sessions
+- `guess/route.js` - Processes guess submissions
+- `leaderboard/route.js` - Leaderboard data management
+- `debug/mapillary/route.js` - Mapillary API debugging
+
+#### React Components (`src/app/components/`)
+- `GameClient.js` - Main game client component
+- `LeafletMap.js` - Interactive map for guess placement
+- `PanoramaViewer.js` - 360° street view display
+- `UsernameModal.js` - Username input modal
+
+### Reusable Components (`src/components/`)
+
+#### shadcn/ui Components (`src/components/ui/`)
+- `alert.jsx` - Alert notifications
+- `avatar.jsx` - User avatars
+- `badge.jsx` - Badge components
+- `button.jsx` - Button variants
+- `card.jsx` - Card layouts
+- `dialog.jsx` - Modal dialogs
+- `form.jsx` - Form components
+- `input.jsx` - Input fields
+- `label.jsx` - Form labels
+- `popover.jsx` - Popover components
+- `progress.jsx` - Progress indicators
+- `separator.jsx` - Visual separators
+- `sheet.jsx` - Side sheets
+- `skeleton.jsx` - Loading skeletons
+- `table.jsx` - Data tables
+- `tabs.jsx` - Tab navigation
+- `tooltip.jsx` - Tooltips
+
+### Utility Libraries (`src/lib/`)
+- `utils.js` - Utility functions including `cn()` for class name merging
+- `game.js` - Game logic and session management
+- `leaderboard.js` - Leaderboard operations with Redis
+- `mapillary.js` - Mapillary API integration
+- `nominatim.js` - Geographic boundary detection
+
+## Documentation (`/docs/`)
+- `project-overview.md` - Project overview and key characteristics
+- `features.md` - Detailed game features documentation
+- `tech-stack.md` - Technology stack and dependencies
+- `development.md` - Development guidelines and commands
+- `game-flow.md` - Complete gameplay flow documentation
+- `project-structure.md` - This file - project organization
+
+## Planning (`/plans/`)
+- Project planning documents and implementation plans
+
+## Public Assets (`public/`)
+- `*.svg` - SVG icons and graphics
+- Static assets served directly by Next.js
\ No newline at end of file
diff --git a/docs/tech-stack.md b/docs/tech-stack.md
new file mode 100644
index 0000000..9a78de7
--- /dev/null
+++ b/docs/tech-stack.md
@@ -0,0 +1,40 @@
+# Tech Stack
+
+## Frontend Framework
+- **Next.js 15.4.6**: React-based full-stack framework with App Router
+- **React 19**: Latest React version for component architecture
+- **Tailwind CSS 4**: Utility-first CSS framework for styling
+
+## Street View & Mapping
+- **Mapillary API**: Panoramic street-level imagery provider
+- **PhotoSphere Viewer**: 360° panorama viewer with zoom and fullscreen
+- **Leaflet**: Interactive mapping library
+- **OpenStreetMap**: Map tile provider for base maps
+
+## Geographic Processing
+- **Nominatim API**: OpenStreetMap geocoding service for city boundaries
+- **Turf.js**: Spatial operations library for point-in-polygon detection and distance calculations
+- **Geospatial Logic**: Server-side coordinate validation and boundary checking
+
+## Data Storage & Session Management
+- **Redis**: Persistent leaderboard storage with automatic trimming
+- **In-Memory Map**: Server-side session storage with 30-minute expiry
+- **Session Isolation**: Secure target coordinate storage per game session
+
+## UI Components & Styling
+- **shadcn/ui**: Component library with "new-york" style
+- **Lucide React**: Icon library
+- **class-variance-authority**: Component variant management
+- **tailwind-merge + clsx**: Dynamic class name handling
+- **CSS Variables**: Theme-based styling system
+
+## Development & Analytics
+- **ESLint**: Code linting with Next.js configuration
+- **Turbopack**: Development server bundler
+- **Vercel Analytics**: User analytics tracking
+- **Vercel Speed Insights**: Performance monitoring
+
+## Key Dependencies
+- **UUID**: Unique session identifier generation
+- **JavaScript Only**: No TypeScript - pure JavaScript implementation
+- **Individual Parameters**: Functions use separate parameters instead of object destructuring
\ No newline at end of file
diff --git a/src/app/api/debug/mapillary/route.js b/src/app/api/debug/mapillary/route.js
new file mode 100644
index 0000000..c8c6ca4
--- /dev/null
+++ b/src/app/api/debug/mapillary/route.js
@@ -0,0 +1,114 @@
+import { NextResponse } from 'next/server';
+
+export async function POST(request) {
+ try {
+ const body = await request.json();
+ const { bbox } = body;
+
+ if (!bbox) {
+ return NextResponse.json({
+ success: false,
+ error: 'Missing bbox parameter'
+ }, { status: 400 });
+ }
+
+ // Get Mapillary access token
+ const MAPILLARY_ACCESS_TOKEN = process.env.MAPILLARY_ACCESS_TOKEN;
+
+ if (!MAPILLARY_ACCESS_TOKEN) {
+ return NextResponse.json({
+ success: false,
+ error: 'Mapillary access token not configured'
+ }, { status: 500 });
+ }
+
+ // Validate bbox format
+ const coords = bbox.split(',').map(coord => parseFloat(coord.trim()));
+ if (coords.length !== 4) {
+ return NextResponse.json({
+ success: false,
+ error: 'Invalid bbox format. Expected: minLng,minLat,maxLng,maxLat'
+ }, { status: 400 });
+ }
+
+ const [minLng, minLat, maxLng, maxLat] = coords;
+
+ // Validate coordinates
+ if (coords.some(coord => isNaN(coord))) {
+ return NextResponse.json({
+ success: false,
+ error: 'All bbox coordinates must be valid numbers'
+ }, { status: 400 });
+ }
+
+ console.log(`Testing Mapillary API with bbox: ${bbox}`);
+
+ // Build Mapillary API URL
+ const apiUrl = `https://graph.mapillary.com/images?access_token=${MAPILLARY_ACCESS_TOKEN}&fields=id,thumb_original_url,geometry,is_pano&limit=50&bbox=${bbox}`;
+
+ const response = await fetch(apiUrl, {
+ headers: { 'Accept': 'application/json' },
+ next: { revalidate: 0 }
+ });
+
+ if (!response.ok) {
+ if (response.status === 401) {
+ return NextResponse.json({
+ success: false,
+ error: 'Mapillary authentication failed - check access token'
+ }, { status: 401 });
+ }
+ return NextResponse.json({
+ success: false,
+ error: `Mapillary API error: ${response.status} ${response.statusText}`
+ }, { status: response.status });
+ }
+
+ const data = await response.json();
+
+ // Add some analysis
+ const analysis = {
+ totalImages: data.data ? data.data.length : 0,
+ panoramicImages: data.data ? data.data.filter(img => img.is_pano).length : 0,
+ regularImages: data.data ? data.data.filter(img => !img.is_pano).length : 0,
+ bboxArea: {
+ width: maxLng - minLng,
+ height: maxLat - minLat,
+ widthKm: ((maxLng - minLng) * 111).toFixed(2),
+ heightKm: ((maxLat - minLat) * 111).toFixed(2)
+ }
+ };
+
+ if (data.data && data.data.length > 0) {
+ return NextResponse.json({
+ success: true,
+ data: data.data,
+ analysis,
+ apiUrl: apiUrl.replace(MAPILLARY_ACCESS_TOKEN, '[TOKEN_HIDDEN]'), // Hide token in response
+ message: `Found ${data.data.length} images in the specified bbox`
+ });
+ } else {
+ return NextResponse.json({
+ success: false,
+ data: [],
+ analysis,
+ apiUrl: apiUrl.replace(MAPILLARY_ACCESS_TOKEN, '[TOKEN_HIDDEN]'),
+ error: 'No images found in the specified bbox'
+ });
+ }
+
+ } catch (error) {
+ console.error('Debug Mapillary API Error:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Internal server error: ' + error.message
+ }, { status: 500 });
+ }
+}
+
+export async function GET() {
+ return NextResponse.json({
+ success: false,
+ error: 'GET method not supported. Use POST with bbox parameter.'
+ }, { status: 405 });
+}
\ No newline at end of file
diff --git a/src/app/api/guess/route.js b/src/app/api/guess/route.js
new file mode 100644
index 0000000..5971c5b
--- /dev/null
+++ b/src/app/api/guess/route.js
@@ -0,0 +1,112 @@
+import { NextResponse } from 'next/server';
+import { submitScore } from '../../../lib/leaderboard.js';
+import { getGameSession, deleteGameSession } from '../../../lib/session.js';
+import { calculateDistance, calculateScore } from '../../../lib/game.js';
+
+export async function POST(request) {
+ try {
+ const body = await request.json();
+ const { username, guessLat, guessLng, sessionId } = body;
+
+ // Validate required fields
+ if (!username || !sessionId ||
+ guessLat === undefined || guessLng === undefined) {
+ return NextResponse.json({
+ success: false,
+ error: 'Missing required fields: username, sessionId, guess coordinates'
+ }, { status: 400 });
+ }
+
+ // Get session data from Redis
+ const session = await getGameSession(sessionId);
+ if (!session) {
+ return NextResponse.json({
+ success: false,
+ error: 'Session not found or expired'
+ }, { status: 400 });
+ }
+
+ // Get target coordinates from session
+ const targetLat = session.exactLocation.lat;
+ const targetLng = session.exactLocation.lng;
+
+ // Validate coordinate ranges
+ const numGuessLat = Number(guessLat);
+ const numGuessLng = Number(guessLng);
+ const numTargetLat = Number(targetLat);
+ const numTargetLng = Number(targetLng);
+
+ // Basic coordinate validation
+ if (Math.abs(numGuessLat) > 90 || Math.abs(numTargetLat) > 90) {
+ return NextResponse.json({
+ success: false,
+ error: 'Invalid latitude values'
+ }, { status: 400 });
+ }
+
+ if (Math.abs(numGuessLng) > 180 || Math.abs(numTargetLng) > 180) {
+ return NextResponse.json({
+ success: false,
+ error: 'Invalid longitude values'
+ }, { status: 400 });
+ }
+
+
+ // Calculate distance between guess and target (server-side)
+ const distance = calculateDistance(
+ numGuessLat, numGuessLng,
+ numTargetLat, numTargetLng
+ );
+
+ // Calculate score based on distance (server-side)
+ const finalScore = calculateScore(distance);
+
+ // Submit to leaderboard with calculated score
+ const leaderboardResult = await submitScore(username.trim(), finalScore);
+
+ // Log the submission for anti-cheat monitoring
+ console.log('Game submission:', {
+ username: username.trim(),
+ distance: `${distance}m`,
+ score: finalScore,
+ coordinates: {
+ guess: [numGuessLat, numGuessLng],
+ target: [numTargetLat, numTargetLng]
+ },
+ timestamp: new Date().toISOString()
+ });
+
+ // Clean up session after successful submission
+ await deleteGameSession(sessionId);
+
+ return NextResponse.json({
+ success: true,
+ gameResult: {
+ distance,
+ score: finalScore,
+ rank: leaderboardResult.entry?.rank || null,
+ exactLocation: {
+ lat: numTargetLat,
+ lng: numTargetLng
+ }
+ },
+ leaderboard: leaderboardResult,
+ message: 'Game result processed successfully'
+ });
+
+ } catch (error) {
+ console.error('Submit Guess Error:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Failed to process game result',
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
+ }, { status: 500 });
+ }
+}
+
+export async function GET(request) {
+ return NextResponse.json({
+ success: false,
+ error: 'GET method not supported for game submissions'
+ }, { status: 405 });
+}
diff --git a/src/app/api/leaderboard/route.js b/src/app/api/leaderboard/route.js
new file mode 100644
index 0000000..2e92c2e
--- /dev/null
+++ b/src/app/api/leaderboard/route.js
@@ -0,0 +1,23 @@
+import { NextResponse } from 'next/server';
+import { getLeaderboard } from '../../../lib/leaderboard.js';
+
+export async function GET(request) {
+ try {
+ // Get top 100 leaderboard entries
+ const leaderboard = await getLeaderboard(100);
+
+ return NextResponse.json({
+ success: true,
+ leaderboard,
+ count: leaderboard.length
+ });
+
+ } catch (error) {
+ console.error('Leaderboard GET Error:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Failed to fetch leaderboard',
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
+ }, { status: 500 });
+ }
+}
diff --git a/src/app/api/new-game/route.js b/src/app/api/new-game/route.js
new file mode 100644
index 0000000..147e420
--- /dev/null
+++ b/src/app/api/new-game/route.js
@@ -0,0 +1,136 @@
+import { NextResponse } from 'next/server';
+import { v4 as uuidv4 } from 'uuid';
+import { cityNames, cityBboxes } from '../../../lib/game.js';
+import { fetchMapillaryImages } from '../../../lib/mapillary.js';
+import { storeGameSession } from '../../../lib/session.js';
+
+// Generate a unique session ID using uuid
+function generateSessionId() {
+ return uuidv4();
+}
+
+export async function GET(request) {
+ const { searchParams } = new URL(request.url);
+ const cityCode = searchParams.get('city');
+ const sessionId = searchParams.get('sessionId');
+
+ if (!cityCode) {
+ return NextResponse.json({ success: false, error: 'Missing city parameter' });
+ }
+
+
+ const cityName = cityNames[cityCode];
+ if (!cityName) {
+ return NextResponse.json({
+ success: false,
+ error: `Unsupported city code: ${cityCode}`
+ }, { status: 400 });
+ }
+
+ try {
+ console.log('Getting images for city:', cityName);
+
+ // Get city bbox
+ const bbox = cityBboxes[cityCode];
+ if (!bbox) {
+ return NextResponse.json({
+ success: false,
+ error: `No bbox found for city: ${cityCode}`
+ }, { status: 400 });
+ }
+
+ // Fetch images from city bbox
+ const imageResult = await fetchMapillaryImages(bbox);
+
+ if (!imageResult.success) {
+ return NextResponse.json({
+ success: false,
+ error: `No street view images found in ${cityName}. This city may not have sufficient Mapillary coverage.`
+ });
+ }
+
+ // Select a random panoramic image
+ const panoImages = imageResult.data.filter(img => img.is_pano);
+ const selectedImage = panoImages.length > 0 ?
+ panoImages[Math.floor(Math.random() * panoImages.length)] :
+ imageResult.data[0];
+
+ const exactLocation = {
+ lat: selectedImage.geometry.coordinates[1],
+ lng: selectedImage.geometry.coordinates[0]
+ };
+
+ // Use the thumbnail URL for now - Mapillary requires special handling for full panoramas
+ const imageUrl = selectedImage.thumb_original_url;
+
+ // Create or update game session
+ const currentSessionId = sessionId || generateSessionId();
+ await storeGameSession(currentSessionId, {
+ sessionId: currentSessionId,
+ cityCode,
+ exactLocation,
+ imageId: selectedImage.id,
+ createdAt: Date.now()
+ });
+
+ console.log(`Session ${currentSessionId} created with exact location:`, exactLocation);
+ console.log(`Using image URL:`, imageUrl);
+
+ return NextResponse.json({
+ success: true,
+ sessionId: currentSessionId,
+ imageData: {
+ id: selectedImage.id,
+ url: imageUrl,
+ isPano: selectedImage.is_pano || false
+ }
+ });
+
+ } catch (error) {
+ console.error('Location/Mapillary API Error:', error);
+
+ // Provide specific error messages based on error type
+ let errorMessage = 'Failed to fetch street view images. Please try again.';
+
+ if (error.message.includes('City not found')) {
+ errorMessage = `City "${cityName}" not found in mapping database.`;
+ } else if (error.message.includes('No polygon data')) {
+ errorMessage = `No boundary data available for "${cityName}".`;
+ } else if (error.message.includes('Mapillary authentication failed')) {
+ errorMessage = 'Mapillary authentication failed. Please check API token.';
+ } else if (error.message.includes('fetch')) {
+ errorMessage = 'Network error. Please check your connection.';
+ }
+
+ return NextResponse.json({
+ success: false,
+ error: errorMessage,
+ details: process.env.NODE_ENV === 'development' ? error.message : undefined
+ }, { status: 500 });
+ }
+}
+
+// Get session data (for debugging)
+export async function POST(request) {
+ const body = await request.json();
+ const { sessionId } = body;
+
+ if (!sessionId) {
+ return NextResponse.json({ success: false, error: 'Missing sessionId' });
+ }
+
+ const session = gameSessions.get(sessionId);
+ if (!session) {
+ return NextResponse.json({ success: false, error: 'Session not found' });
+ }
+
+ return NextResponse.json({
+ success: true,
+ session: {
+ sessionId: session.sessionId,
+ cityCode: session.cityCode,
+ createdAt: session.createdAt
+ // Don't expose exact location for security
+ }
+ });
+}
diff --git a/src/app/api/skip/route.js b/src/app/api/skip/route.js
new file mode 100644
index 0000000..111ec3a
--- /dev/null
+++ b/src/app/api/skip/route.js
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server';
+import { deleteGameSession } from '../../../lib/session.js';
+
+export async function POST(request) {
+ try {
+ const body = await request.json();
+ const { sessionId } = body;
+
+ if (!sessionId) {
+ return NextResponse.json({
+ success: false,
+ error: 'Missing sessionId'
+ }, { status: 400 });
+ }
+
+ // Clean up the session
+ await deleteGameSession(sessionId);
+
+ return NextResponse.json({
+ success: true,
+ message: 'Session cleaned up'
+ });
+
+ } catch (error) {
+ console.error('Session cleanup error:', error);
+ return NextResponse.json({
+ success: false,
+ error: 'Failed to cleanup session'
+ }, { status: 500 });
+ }
+}
\ No newline at end of file
diff --git a/src/app/components/GameClient.js b/src/app/components/GameClient.js
new file mode 100644
index 0000000..97904f6
--- /dev/null
+++ b/src/app/components/GameClient.js
@@ -0,0 +1,571 @@
+"use client";
+
+import { useEffect, useState, useRef, useCallback } from 'react';
+import { useSearchParams, useRouter } from 'next/navigation';
+import dynamic from 'next/dynamic';
+import PanoramaViewer from './PanoramaViewer';
+
+const LeafletMap = dynamic(() => import('./LeafletMap'), {
+ ssr: false,
+ loading: () =>
Loading map...
+});
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Badge } from '@/components/ui/badge';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Skeleton } from '@/components/ui/skeleton';
+import {
+ CITIES,
+ cityCenters,
+ cityNames,
+ formatDistance,
+ getUsername,
+ getResultMessage
+} from '../../lib/game';
+
+export default function GameClient() {
+ const searchParams = useSearchParams();
+ const router = useRouter();
+ const [location, setLocation] = useState('TPHCM');
+ const [imageData, setImageData] = useState(null);
+ const [sessionId, setSessionId] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [guessCoordinates, setGuessCoordinates] = useState(null);
+ const [showResult, setShowResult] = useState(false);
+ const [distance, setDistance] = useState(0);
+ const [score, setScore] = useState(0);
+ const [exactLocation, setExactLocation] = useState(null);
+ const [showDonate, setShowDonate] = useState(false);
+ const [username, setUsernameState] = useState('');
+ const [leaderboardRank, setLeaderboardRank] = useState(null);
+ const [mapCenter, setMapCenter] = useState([10.8231, 106.6297]); // Default to Ho Chi Minh
+ const resultMapRef = useRef(null);
+ const resultLeafletMapRef = useRef(null);
+
+ const loadLibrariesAndInitialize = useCallback(async (locationCode) => {
+ setLoading(true);
+ try {
+ // Set map center based on location
+ const center = cityCenters[locationCode];
+ if (center) {
+ setMapCenter(center);
+ }
+
+ await getRandomImage(locationCode);
+ } catch (error) {
+ console.error('Failed to initialize:', error);
+ setLoading(false);
+ }
+ }, []); // Remove sessionId dependency to prevent infinite loop
+
+ useEffect(() => {
+ const locationParam = searchParams.get('location') || 'TPHCM';
+ setLocation(locationParam);
+
+ // Get existing username (should be set from home page)
+ const existingUsername = getUsername();
+ setUsernameState(existingUsername || '');
+
+ // Initialize the game
+ loadLibrariesAndInitialize(locationParam);
+ }, [searchParams]); // Remove loadLibrariesAndInitialize from deps to prevent infinite loop
+
+
+
+ const submitGameResult = async (guessCoords) => {
+ if (!guessCoords || !sessionId) return null;
+
+ // Use username or default to 'Anonymous' if not set
+ const playerName = username || 'Anonymous';
+
+ try {
+ const response = await fetch('/api/guess', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({
+ username: playerName,
+ guessLat: guessCoords[0],
+ guessLng: guessCoords[1],
+ sessionId
+ })
+ });
+
+ const data = await response.json();
+ if (data.success) {
+ console.log(`Game result processed. Distance: ${data.gameResult.distance}m, Score: ${data.gameResult.score}, Rank: ${data.gameResult.rank}`);
+ return data.gameResult;
+ } else {
+ console.error('Failed to submit game result:', data.error);
+ return null;
+ }
+ } catch (error) {
+ console.error('Failed to submit game result:', error);
+ return null;
+ }
+ };
+
+ const handlePanoramaReady = useCallback(() => {
+ setLoading(false);
+ console.log('Panorama viewer ready');
+ }, []);
+
+ const handlePanoramaError = useCallback((error) => {
+ console.error('Panorama error:', error);
+ setLoading(false);
+ }, []);
+
+ const handleMapClick = (coordinates) => {
+ setGuessCoordinates([coordinates.lat, coordinates.lng]);
+ console.log('Map clicked at:', coordinates);
+ };
+
+ const getRandomImage = async (locationCode) => {
+ try {
+ console.log('Fetching random image for city:', locationCode);
+ const url = sessionId ?
+ `/api/new-game?city=${locationCode}&sessionId=${sessionId}` :
+ `/api/new-game?city=${locationCode}`;
+
+ const response = await fetch(url);
+ const data = await response.json();
+
+ if (data.success) {
+ console.log('API response data:', data);
+ setSessionId(data.sessionId);
+ const newImageData = {
+ id: data.imageData.id,
+ url: data.imageData.url,
+ isPano: data.imageData.isPano
+ };
+ console.log('Setting imageData:', newImageData);
+ setImageData(newImageData);
+ // Temporarily set loading to false here to debug
+ console.log('Setting loading to false');
+ setLoading(false);
+
+ console.log(`Image loaded from city bbox`);
+ } else {
+ throw new Error(data.error || 'No images found');
+ }
+ } catch (error) {
+ console.error('Error fetching image after all retries:', error);
+ console.error('Error details:', error.message);
+
+ // Set imageData to null to trigger error display in PanoramaViewer
+ setImageData(null);
+ setSessionId(null);
+
+ setLoading(false);
+
+ // Only show error after all retries are complete
+ // The API already handles retries internally
+ alert(`Failed to load street view image after trying different search areas: ${error.message || 'No images found'}`);
+ }
+ };
+
+
+ const handleSubmitGuess = async () => {
+ if (!guessCoordinates || !imageData) return;
+
+ setLoading(true);
+
+ try {
+ // Submit to server for processing
+ const result = await submitGameResult(guessCoordinates);
+
+ if (result) {
+ // Use server-calculated values
+ setDistance(result.distance);
+ setScore(result.score);
+ setExactLocation(result.exactLocation);
+ setLeaderboardRank(result.rank);
+ } else {
+ // Fallback: no calculation possible without exact location
+ setDistance(99999);
+ setScore(0);
+ setExactLocation(null);
+ setLeaderboardRank(null);
+ }
+ } catch (error) {
+ console.error('Error submitting guess:', error);
+
+ // Fallback: no calculation possible
+ setDistance(99999);
+ setScore(0);
+ setExactLocation(null);
+ setLeaderboardRank(null);
+ }
+
+ setLoading(false);
+ setShowResult(true);
+ };
+
+ const handleNextRound = () => {
+ setShowResult(false);
+ setGuessCoordinates(null);
+ setLeaderboardRank(null);
+ setExactLocation(null);
+ setSessionId(null);
+ getRandomImage(location);
+ };
+
+ const handleSkipGuess = async () => {
+ if (!imageData) return;
+
+ setLoading(true);
+
+ try {
+ // Just clean up the current session and move to next image
+ if (sessionId) {
+ await fetch('/api/skip', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ sessionId })
+ });
+ }
+ } catch (error) {
+ console.error('Error cleaning up session:', error);
+ }
+
+ // Reset state and load next image
+ setGuessCoordinates(null);
+ setLeaderboardRank(null);
+ setExactLocation(null);
+ setSessionId(null);
+ getRandomImage(location);
+ };
+
+ const handleGoBack = () => {
+ router.push('/');
+ };
+
+ // Create result map when dialog opens
+ useEffect(() => {
+ console.log('Result map effect triggered:', { showResult, exactLocation, guessCoordinates, hasRef: !!resultMapRef.current });
+
+ if (showResult && guessCoordinates) {
+ // Clean up existing map
+ if (resultLeafletMapRef.current) {
+ resultLeafletMapRef.current.remove();
+ resultLeafletMapRef.current = null;
+ }
+
+ // Use a more aggressive approach to wait for DOM
+ const initializeMap = async () => {
+ if (!resultMapRef.current) {
+ console.log('Map ref not available, retrying...');
+ setTimeout(initializeMap, 100);
+ return;
+ }
+
+ console.log('Map ref found, initializing map...');
+ try {
+ // Dynamically import Leaflet only on client side
+ const L = (await import('leaflet')).default;
+ await import('leaflet/dist/leaflet.css');
+
+ // Fix default marker icons issue (same as in LeafletMap component)
+ delete L.Icon.Default.prototype._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
+ iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
+ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
+ });
+
+ // Create map with explicit size settings
+ const map = L.map(resultMapRef.current, {
+ preferCanvas: true,
+ attributionControl: true
+ });
+
+ // Add tile layer
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(map);
+
+ const markers = [];
+
+ // Guess marker (red circle) - always show this
+ const redIcon = L.divIcon({
+ className: 'custom-div-icon',
+ html: '',
+ iconSize: [20, 20],
+ iconAnchor: [10, 10]
+ });
+
+ const guessMarker = L.marker([guessCoordinates[0], guessCoordinates[1]], {
+ icon: redIcon
+ }).addTo(map).bindPopup("Your Guess");
+ markers.push(guessMarker);
+
+ // True location marker (green circle) - only if exactLocation exists
+ if (exactLocation) {
+ const greenIcon = L.divIcon({
+ className: 'custom-div-icon',
+ html: '',
+ iconSize: [20, 20],
+ iconAnchor: [10, 10]
+ });
+
+ const trueLocationMarker = L.marker([exactLocation.lat, exactLocation.lng], {
+ icon: greenIcon
+ }).addTo(map).bindPopup("Actual Location");
+ markers.push(trueLocationMarker);
+
+ // Draw line between markers
+ L.polyline([
+ [exactLocation.lat, exactLocation.lng],
+ [guessCoordinates[0], guessCoordinates[1]]
+ ], { color: 'blue', weight: 3 }).addTo(map);
+ }
+
+ // Fit bounds to show all markers
+ if (markers.length > 1) {
+ const featureGroup = new L.featureGroup(markers);
+ map.fitBounds(featureGroup.getBounds(), {
+ padding: [20, 20],
+ maxZoom: 16
+ });
+ } else {
+ // Only guess marker, center on it
+ map.setView([guessCoordinates[0], guessCoordinates[1]], 13);
+ }
+
+ // Force map to recognize its size after a delay
+ setTimeout(() => {
+ map.invalidateSize();
+ }, 100);
+
+ // Additional invalidation after longer delay to ensure tiles load
+ setTimeout(() => {
+ map.invalidateSize();
+ }, 500);
+
+ resultLeafletMapRef.current = map;
+ } catch (error) {
+ console.error('Error creating result map:', error);
+ }
+ };
+
+ // Start trying to initialize the map
+ setTimeout(initializeMap, 300);
+ }
+ }, [showResult, exactLocation, guessCoordinates]);
+
+ // Clean up result map when dialog closes
+ useEffect(() => {
+ if (!showResult && resultLeafletMapRef.current) {
+ resultLeafletMapRef.current.remove();
+ resultLeafletMapRef.current = null;
+ }
+ }, [showResult]);
+
+ console.log('GameClient render - loading state:', loading);
+
+ if (loading) {
+ console.log('Showing main loading screen');
+ return (
+
+
+
+
+
+
+
Loading panoramic image...
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
VNGEOGUESSR
+
+ {cityNames[location] || location}
+
+
+
+
+
+
+ {/* Game Content */}
+
+ {/* Panorama Viewer */}
+
+ {imageData ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Map and Submit */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Result Modal */}
+
+
+
+ {/* Donate Modal */}
+
+
+
+ );
+}
diff --git a/src/app/components/LeafletMap.js b/src/app/components/LeafletMap.js
new file mode 100644
index 0000000..ac13b3f
--- /dev/null
+++ b/src/app/components/LeafletMap.js
@@ -0,0 +1,130 @@
+"use client";
+
+import { useEffect, useRef } from 'react';
+import L from 'leaflet';
+import 'leaflet/dist/leaflet.css';
+
+export default function LeafletMap({
+ center,
+ zoom = 10,
+ bbox = null,
+ onMapClick,
+ onReady,
+ className = "w-full h-full min-h-[400px]"
+}) {
+ const mapRef = useRef(null);
+ const leafletMapRef = useRef(null);
+ const markersRef = useRef([]);
+ const onMapClickRef = useRef(onMapClick);
+ const onReadyRef = useRef(onReady);
+
+ // Update refs when callbacks change
+ onMapClickRef.current = onMapClick;
+ onReadyRef.current = onReady;
+
+ useEffect(() => {
+ const initMap = async () => {
+ if (!mapRef.current || leafletMapRef.current) return;
+
+ try {
+
+ // Fix default marker icons issue
+ delete L.Icon.Default.prototype._getIconUrl;
+ L.Icon.Default.mergeOptions({
+ iconRetinaUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png',
+ iconUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png',
+ shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png',
+ });
+
+ // Create map
+ const map = L.map(mapRef.current);
+
+ // Set initial view - use bbox if provided, otherwise center/zoom
+ if (bbox) {
+ // bbox format: [west, south, east, north]
+ const bounds = L.latLngBounds(
+ L.latLng(bbox[1], bbox[0]), // southwest corner
+ L.latLng(bbox[3], bbox[2]) // northeast corner
+ );
+ map.fitBounds(bounds, { padding: [20, 20] });
+ } else {
+ map.setView(center, zoom);
+ }
+
+ // Add tile layer
+ L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
+ maxZoom: 19,
+ attribution: '© OpenStreetMap contributors'
+ }).addTo(map);
+
+ // Handle click events
+ map.on('click', (e) => {
+ if (onMapClickRef.current) {
+ // Clear existing markers
+ markersRef.current.forEach(marker => {
+ map.removeLayer(marker);
+ });
+ markersRef.current = [];
+
+ // Add new marker
+ const marker = L.marker([e.latlng.lat, e.latlng.lng]).addTo(map);
+ markersRef.current.push(marker);
+
+ // Call callback
+ onMapClickRef.current({
+ lat: e.latlng.lat,
+ lng: e.latlng.lng
+ });
+ }
+ });
+
+ leafletMapRef.current = map;
+
+ if (onReadyRef.current) {
+ onReadyRef.current(map);
+ }
+
+ } catch (error) {
+ console.error('Error initializing Leaflet map:', error);
+ }
+ };
+
+ initMap();
+
+ // Cleanup function
+ return () => {
+ if (leafletMapRef.current) {
+ try {
+ leafletMapRef.current.remove();
+ } catch (error) {
+ console.warn('Error removing map:', error);
+ }
+ leafletMapRef.current = null;
+ markersRef.current = [];
+ }
+ };
+ }, [bbox, center, zoom]); // Include props used in initialization
+
+ // Update view when props change
+ useEffect(() => {
+ if (leafletMapRef.current) {
+ if (bbox) {
+ // bbox format: [west, south, east, north]
+ const bounds = L.latLngBounds(
+ L.latLng(bbox[1], bbox[0]), // southwest corner
+ L.latLng(bbox[3], bbox[2]) // northeast corner
+ );
+ leafletMapRef.current.fitBounds(bounds, { padding: [20, 20] });
+ } else if (center) {
+ leafletMapRef.current.setView(center, zoom);
+ }
+ }
+ }, [center, zoom, bbox]);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/components/PanoramaViewer.js b/src/app/components/PanoramaViewer.js
new file mode 100644
index 0000000..185ca26
--- /dev/null
+++ b/src/app/components/PanoramaViewer.js
@@ -0,0 +1,148 @@
+"use client";
+
+import { useEffect, useRef, memo } from 'react';
+import { Viewer } from '@photo-sphere-viewer/core';
+import '@photo-sphere-viewer/core/index.css';
+
+function PanoramaViewer({ imageUrl, onReady, onError }) {
+ const containerRef = useRef(null);
+ const viewerRef = useRef(null);
+ const onReadyRef = useRef(onReady);
+ const onErrorRef = useRef(onError);
+
+ // Update refs when callbacks change
+ onReadyRef.current = onReady;
+ onErrorRef.current = onError;
+
+ console.log('PanoramaViewer render - imageUrl:', imageUrl);
+
+ useEffect(() => {
+ console.log('PanoramaViewer useEffect triggered - imageUrl:', imageUrl);
+ console.log('Container ref:', containerRef.current);
+
+ let viewer = null;
+
+ const initViewer = async () => {
+ console.log('initViewer called');
+ if (!containerRef.current) {
+ console.error('Container ref is null');
+ return;
+ }
+
+ if (!imageUrl) {
+ // Show loading or error state
+ containerRef.current.innerHTML = `
+
+
Loading panoramic image...
+
+ `;
+ return;
+ }
+
+ console.log('Initializing PhotoSphere Viewer with URL:', imageUrl);
+ console.log('Container element:', containerRef.current);
+
+ try {
+ // Clean up existing viewer
+ if (viewerRef.current) {
+ viewerRef.current.destroy();
+ }
+
+ // Set up container with id for PhotoSphere Viewer
+ containerRef.current.innerHTML = '';
+
+ console.log('Creating PhotoSphere Viewer...');
+
+ // Create new viewer with your specified config
+ viewer = new Viewer({
+ container: document.getElementById("pano"),
+ panorama: imageUrl,
+ loadingImg: null,
+ defaultYaw: 0,
+ defaultZoomLvl: -60,
+ navbar: ["zoom", "fullscreen"],
+ mousewheel: true,
+ touchmoveTwoFingers: true
+ });
+
+ console.log('PhotoSphere Viewer created successfully');
+
+ viewer.addEventListener('ready', () => {
+ console.log("PhotoSphere Viewer loaded successfully");
+ if (onReadyRef.current) onReadyRef.current();
+ });
+
+ viewer.addEventListener('panorama-loaded', () => {
+ console.log("Panorama image loaded");
+ if (onReadyRef.current) onReadyRef.current();
+ });
+
+ viewer.addEventListener('panorama-error', (error) => {
+ console.error("Error loading panorama:", error);
+ // Fallback to regular image on panorama error
+ if (containerRef.current) {
+ containerRef.current.innerHTML = `
+
+ `;
+ if (onReadyRef.current) onReadyRef.current();
+ }
+ });
+
+ // Also handle general errors
+ viewer.addEventListener('error', (error) => {
+ console.error("PhotoSphere Viewer error:", error);
+ if (onErrorRef.current) onErrorRef.current(error);
+ });
+
+ viewerRef.current = viewer;
+
+ } catch (error) {
+ console.error('Error initializing PhotoSphere Viewer:', error);
+
+ // Fallback to regular image
+ if (containerRef.current) {
+ containerRef.current.innerHTML = `
+
+ `;
+
+ if (onReadyRef.current) onReadyRef.current();
+ }
+
+ if (onErrorRef.current) onErrorRef.current(error);
+ }
+ };
+
+ initViewer();
+
+ // Cleanup function
+ return () => {
+ if (viewerRef.current) {
+ try {
+ viewerRef.current.destroy();
+ } catch (error) {
+ console.warn('Error destroying viewer:', error);
+ }
+ viewerRef.current = null;
+ }
+ };
+ }, [imageUrl]); // Remove onReady and onError from deps to prevent constant reinitialization
+
+ return (
+
+ );
+}
+
+export default memo(PanoramaViewer);
\ No newline at end of file
diff --git a/src/app/components/UsernameModal.js b/src/app/components/UsernameModal.js
new file mode 100644
index 0000000..a7e444e
--- /dev/null
+++ b/src/app/components/UsernameModal.js
@@ -0,0 +1,104 @@
+"use client";
+
+import { useState } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+
+export default function UsernameModal({ isOpen, onSubmit, onClose }) {
+ const [username, setUsername] = useState('');
+ const [error, setError] = useState('');
+
+ const handleSubmit = (e) => {
+ e.preventDefault();
+
+ const trimmedUsername = username.trim();
+
+ if (!trimmedUsername) {
+ setError('Please enter a username');
+ return;
+ }
+
+ if (trimmedUsername.length < 2) {
+ setError('Username must be at least 2 characters');
+ return;
+ }
+
+ if (trimmedUsername.length > 20) {
+ setError('Username must be less than 20 characters');
+ return;
+ }
+
+ // Basic validation - only letters, numbers, and common symbols
+ const validUsername = /^[a-zA-Z0-9_-]+$/.test(trimmedUsername);
+ if (!validUsername) {
+ setError('Username can only contain letters, numbers, hyphens, and underscores');
+ return;
+ }
+
+ onSubmit(trimmedUsername);
+ setError('');
+ setUsername('');
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/debug/page.js b/src/app/debug/page.js
new file mode 100644
index 0000000..0182577
--- /dev/null
+++ b/src/app/debug/page.js
@@ -0,0 +1,314 @@
+"use client";
+
+import { useState, useCallback, useEffect } from 'react';
+import Link from 'next/link';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Badge } from '@/components/ui/badge';
+import { Alert, AlertDescription } from '@/components/ui/alert';
+import dynamic from 'next/dynamic';
+
+// Dynamic import for client-side only components
+const LeafletMap = dynamic(() => import('../components/LeafletMap'), {
+ ssr: false,
+ loading: () =>
+});
+
+export default function DebugPage() {
+ const [bbox, setBbox] = useState('105.8,21.0,105.9,21.1'); // Default: Hanoi area
+ const [parsedBbox, setParsedBbox] = useState(null);
+ const [mapCenter, setMapCenter] = useState([21.05, 105.85]);
+ const [mapZoom, setMapZoom] = useState(12);
+ const [error, setError] = useState('');
+ const [mapillaryResults, setMapillaryResults] = useState(null);
+ const [loading, setLoading] = useState(false);
+
+ // Parse bbox string to coordinates
+ const parseBbox = (bboxString) => {
+ const coords = bboxString.split(',').map(coord => parseFloat(coord.trim()));
+ if (coords.length !== 4) {
+ throw new Error('Bbox must have exactly 4 coordinates: minLng,minLat,maxLng,maxLat');
+ }
+
+ const [minLng, minLat, maxLng, maxLat] = coords;
+
+ // Validate coordinates
+ if (isNaN(minLng) || isNaN(minLat) || isNaN(maxLng) || isNaN(maxLat)) {
+ throw new Error('All coordinates must be valid numbers');
+ }
+
+ if (minLng >= maxLng || minLat >= maxLat) {
+ throw new Error('Invalid bbox: min values must be less than max values');
+ }
+
+ if (Math.abs(minLat) > 90 || Math.abs(maxLat) > 90) {
+ throw new Error('Latitude must be between -90 and 90');
+ }
+
+ if (Math.abs(minLng) > 180 || Math.abs(maxLng) > 180) {
+ throw new Error('Longitude must be between -180 and 180');
+ }
+
+ return { minLng, minLat, maxLng, maxLat };
+ };
+
+ const handleVisualizeBbox = () => {
+ try {
+ setError('');
+ const parsed = parseBbox(bbox);
+ setParsedBbox(parsed);
+
+ // Set map center to bbox center
+ const centerLat = (parsed.minLat + parsed.maxLat) / 2;
+ const centerLng = (parsed.minLng + parsed.maxLng) / 2;
+ setMapCenter([centerLat, centerLng]);
+
+ // Adjust zoom based on bbox size
+ const latRange = parsed.maxLat - parsed.minLat;
+ const lngRange = parsed.maxLng - parsed.minLng;
+ const maxRange = Math.max(latRange, lngRange);
+
+ let zoom = 10;
+ if (maxRange < 0.01) zoom = 16;
+ else if (maxRange < 0.02) zoom = 15;
+ else if (maxRange < 0.05) zoom = 14;
+ else if (maxRange < 0.1) zoom = 13;
+ else if (maxRange < 0.2) zoom = 12;
+ else if (maxRange < 0.5) zoom = 11;
+
+ setMapZoom(zoom);
+
+ } catch (err) {
+ setError(err.message);
+ setParsedBbox(null);
+ }
+ };
+
+ const handleTestMapillary = async () => {
+ if (!parsedBbox) {
+ setError('Please visualize the bbox first');
+ return;
+ }
+
+ setLoading(true);
+ setMapillaryResults(null);
+
+ try {
+ const response = await fetch('/api/debug/mapillary', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ bbox })
+ });
+
+ const data = await response.json();
+ setMapillaryResults(data);
+
+ } catch (err) {
+ setError('Failed to test Mapillary API: ' + err.message);
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const handleMapReady = useCallback(async (map) => {
+ if (parsedBbox && map && typeof window !== 'undefined') {
+ // Clear existing layers
+ map.eachLayer((layer) => {
+ if (layer.options && layer.options.debugLayer) {
+ map.removeLayer(layer);
+ }
+ });
+
+ try {
+ // Dynamic import of Leaflet for client-side only
+ const L = (await import('leaflet')).default;
+
+ // Add bbox rectangle
+ const rectangle = L.rectangle(
+ [[parsedBbox.minLat, parsedBbox.minLng], [parsedBbox.maxLat, parsedBbox.maxLng]],
+ {
+ color: '#ff0000',
+ weight: 2,
+ fillOpacity: 0.1,
+ debugLayer: true
+ }
+ ).addTo(map);
+
+ // Add center point
+ const centerLat = (parsedBbox.minLat + parsedBbox.maxLat) / 2;
+ const centerLng = (parsedBbox.minLng + parsedBbox.maxLng) / 2;
+
+ L.marker([centerLat, centerLng], { debugLayer: true })
+ .addTo(map)
+ .bindPopup(`Center: ${centerLat.toFixed(6)}, ${centerLng.toFixed(6)}`);
+
+ // Fit map to bbox
+ map.fitBounds([[parsedBbox.minLat, parsedBbox.minLng], [parsedBbox.maxLat, parsedBbox.maxLng]]);
+ } catch (error) {
+ console.error('Error adding bbox visualization:', error);
+ }
+ }
+ }, [parsedBbox]);
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+ {/* Controls Panel */}
+
+
+ Bbox Input
+
+
+
+
+
setBbox(e.target.value)}
+ placeholder="105.8,21.0,105.9,21.1"
+ className="mt-1"
+ />
+
+ Format: longitude1,latitude1,longitude2,latitude2
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+
+
+
+
+ {parsedBbox && (
+
+
+ Parsed Bbox:
+
+
Min: {parsedBbox.minLng.toFixed(6)}, {parsedBbox.minLat.toFixed(6)}
+
Max: {parsedBbox.maxLng.toFixed(6)}, {parsedBbox.maxLat.toFixed(6)}
+
Size: {((parsedBbox.maxLng - parsedBbox.minLng) * 111).toFixed(2)}km × {((parsedBbox.maxLat - parsedBbox.minLat) * 111).toFixed(2)}km
+
+
+
+ )}
+
+ {mapillaryResults && (
+
+
+ Mapillary Results:
+ {mapillaryResults.success ? (
+
+
+ Found {mapillaryResults.data.length} images
+
+
+
Panoramic images: {mapillaryResults.data.filter(img => img.is_pano).length}
+
Regular images: {mapillaryResults.data.filter(img => !img.is_pano).length}
+ {mapillaryResults.analysis && (
+
+
Area: {mapillaryResults.analysis.bboxArea.widthKm}km × {mapillaryResults.analysis.bboxArea.heightKm}km
+
Density: {(mapillaryResults.data.length / (parseFloat(mapillaryResults.analysis.bboxArea.widthKm) * parseFloat(mapillaryResults.analysis.bboxArea.heightKm))).toFixed(1)} images/km²
+
+ )}
+
+
+ ) : (
+
+
+ Error: {mapillaryResults.error}
+
+
+ )}
+
+
+ )}
+
+
+
+ {/* Map Panel */}
+
+
+ Map Visualization
+
+
+
+
+
+
+ Red rectangle shows the bbox area. Center marker shows the bbox center point.
+
+
+
+
+
+ {/* Sample Bboxes */}
+
+
+ Sample Bboxes
+
+
+
+ {[
+ { name: 'Hanoi Center', bbox: '105.8,21.0,105.9,21.1' },
+ { name: 'HCMC Center', bbox: '106.6,10.7,106.8,10.9' },
+ { name: 'Da Nang', bbox: '108.1,16.0,108.3,16.1' },
+ { name: 'Small Area (1km)', bbox: '105.85,21.03,105.86,21.04' },
+ { name: 'Large Area (10km)', bbox: '105.5,20.8,106.2,21.3' },
+ { name: 'Custom', bbox: bbox }
+ ].map((sample) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/app/game/page.js b/src/app/game/page.js
new file mode 100644
index 0000000..c9f6fc3
--- /dev/null
+++ b/src/app/game/page.js
@@ -0,0 +1,7 @@
+"use client";
+
+import GameClient from '../components/GameClient';
+
+export default function GamePage() {
+ return ;
+}
\ No newline at end of file
diff --git a/src/app/globals.css b/src/app/globals.css
index dc98be7..557d90a 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -1,6 +1,12 @@
@import "tailwindcss";
@import "tw-animate-css";
+/* Import Leaflet CSS */
+@import "leaflet/dist/leaflet.css";
+
+/* Import PhotoSphere Viewer CSS */
+@import "@photo-sphere-viewer/core/index.css";
+
@custom-variant dark (&:is(.dark *));
@theme inline {
@@ -120,3 +126,13 @@
@apply bg-background text-foreground;
}
}
+
+/* Ensure dialogs appear above Leaflet maps */
+[role="dialog"] {
+ z-index: 9999 !important;
+}
+
+/* Ensure dialog overlay appears above Leaflet maps */
+[data-radix-popper-content-wrapper] {
+ z-index: 9999 !important;
+}
diff --git a/src/app/page.js b/src/app/page.js
index d0d0a6a..93ce40f 100644
--- a/src/app/page.js
+++ b/src/app/page.js
@@ -1,103 +1,288 @@
-import Image from "next/image";
+"use client";
+
+import Link from "next/link";
+import { useState, useEffect } from "react";
+import { Button } from '@/components/ui/button';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { Badge } from '@/components/ui/badge';
+import { Skeleton } from '@/components/ui/skeleton';
+import UsernameModal from './components/UsernameModal';
+import { getUsername, setUsername, cities } from '../lib/game';
export default function Home() {
- return (
-
-
-
-
- -
- Get started by editing{" "}
-
- src/app/page.js
-
- .
-
- -
- Save and see your changes instantly.
-
-
+ const [showDonateModal, setShowDonateModal] = useState(false);
+ const [showUsernameModal, setShowUsernameModal] = useState(false);
+ const [showLeaderboardModal, setShowLeaderboardModal] = useState(false);
+ const [username, setUsernameState] = useState('');
+ const [leaderboard, setLeaderboard] = useState([]);
+ const [loadingLeaderboard, setLoadingLeaderboard] = useState(false);
-
-
-
- Deploy now
-
-
- Read our docs
-
+ useEffect(() => {
+ // Check for existing username on page load
+ const existingUsername = getUsername();
+ if (existingUsername) {
+ setUsernameState(existingUsername);
+ } else {
+ setShowUsernameModal(true);
+ }
+ }, []);
+
+ const handleUsernameSubmit = (newUsername) => {
+ setUsername(newUsername);
+ setUsernameState(newUsername);
+ setShowUsernameModal(false);
+ };
+
+ const handleUsernameSkip = () => {
+ setShowUsernameModal(false);
+ };
+
+ const fetchLeaderboard = async () => {
+ setLoadingLeaderboard(true);
+ try {
+ const response = await fetch('/api/leaderboard');
+ const data = await response.json();
+
+ if (data.success) {
+ setLeaderboard(data.leaderboard);
+ } else {
+ console.error('Failed to fetch leaderboard:', data.error);
+ }
+ } catch (error) {
+ console.error('Error fetching leaderboard:', error);
+ } finally {
+ setLoadingLeaderboard(false);
+ }
+ };
+
+ const handleLeaderboardClick = () => {
+ setShowLeaderboardModal(true);
+ fetchLeaderboard();
+ };
+
+ const getRankIcon = (rank) => {
+ switch (rank) {
+ case 1: return '🥇';
+ case 2: return '🥈';
+ case 3: return '🥉';
+ default: return `#${rank}`;
+ }
+ };
+
+ const getScoreColor = (score) => {
+ if (score === 5) return 'text-green-600';
+ if (score >= 4) return 'text-blue-600';
+ if (score >= 3) return 'text-yellow-600';
+ if (score >= 2) return 'text-orange-600';
+ return 'text-red-600';
+ };
+
+ const isCurrentUser = (leaderboardUsername) => {
+ return username && leaderboardUsername === username;
+ };
+
+ return (
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+
+
+
+ {/* Main Content */}
+
+
WELCOME TO VNGEOGUESSR
+
+
+
+ {/* Left Panel - Instructions */}
+
+
+ HOW TO PLAY
+
+
+
+
1. Choose your city
+
2. View the street-level panoramic image
+
3. Guess the location on the map as accurately as possible
+
4. Earn points based on your accuracy!
+
+
+
+
+ {/* Right Panel - City Selection */}
+
+
+ SELECT CITY
+
+
+
+ {cities.map((city) => (
+
+ ))}
+
+
+
+
-
-
+
+
🔧
+
+
+
);
}
diff --git a/src/lib/game.js b/src/lib/game.js
new file mode 100644
index 0000000..e4fa699
--- /dev/null
+++ b/src/lib/game.js
@@ -0,0 +1,126 @@
+import * as turf from '@turf/turf';
+
+// Cities enum-like structure
+export const CITIES = {
+ HN: {
+ code: 'HN',
+ name: 'Ha Noi',
+ center: [21.0285, 105.8542],
+ bbox: [105.77, 20.96, 105.88, 21.05]
+ },
+ DN: {
+ code: 'DN',
+ name: 'Da Nang',
+ center: [16.0544, 108.2022],
+ bbox: [108.17, 16, 108.25, 16.1]
+ },
+ TPHCM: {
+ code: 'TPHCM',
+ name: 'Ho Chi Minh',
+ center: [10.8231, 106.6297],
+ bbox: [106.62, 10.71, 106.75, 10.83]
+ },
+ DL: {
+ code: 'DL',
+ name: 'Da Lat',
+ center: [11.9404, 108.4583],
+ bbox: [108.38, 11.89, 108.50, 12.00]
+ },
+ DH: {
+ code: 'DH',
+ name: 'Duc Hoa (Long An)',
+ center: [10.8888, 106.3825],
+ bbox: [106.35, 10.85, 106.45, 10.95]
+ }
+};
+
+// Helper functions for backward compatibility
+export const cityCenters = Object.fromEntries(
+ Object.values(CITIES).map(city => [city.code, city.center])
+);
+
+export const cityNames = Object.fromEntries(
+ Object.values(CITIES).map(city => [city.code, city.name])
+);
+
+
+export const cityBboxes = Object.fromEntries(
+ Object.values(CITIES).map(city => [city.code, city.bbox])
+);
+
+// Cities list for UI components
+export const cities = Object.values(CITIES).map(city => ({
+ code: city.code,
+ name: city.name.toUpperCase()
+}));
+
+// Calculate distance between two coordinates in meters using Turf
+export function calculateDistance(lat1, lon1, lat2, lon2) {
+ if (lat1 === lat2 && lon1 === lon2) return 0;
+
+ try {
+ const from = turf.point([lon1, lat1]);
+ const to = turf.point([lon2, lat2]);
+ const distanceKm = turf.distance(from, to, { units: 'kilometers' });
+
+ // Convert to meters and round
+ return Math.round(distanceKm * 1000);
+ } catch (error) {
+ console.error('Error calculating distance with Turf:', error);
+ throw new Error(`Failed to calculate distance: ${error.message}`);
+ }
+}
+
+// Calculate score based on distance (0-5 points scale)
+export function calculateScore(distance) {
+ if (distance <= 50) return 5;
+ if (distance <= 100) return 4;
+ if (distance <= 200) return 3;
+ if (distance <= 500) return 2;
+ if (distance <= 1000) return 1;
+ return 0;
+}
+
+// Format distance for display
+export function formatDistance(distance) {
+ if (distance < 1000) {
+ return `${distance}m`;
+ } else {
+ return `${(distance / 1000).toFixed(2)}km`;
+ }
+}
+
+
+// Get or set username from localStorage
+export function getUsername() {
+ if (typeof window === 'undefined') return null;
+ return localStorage.getItem('vngeoguessr_username');
+}
+
+export function setUsername(username) {
+ if (typeof window === 'undefined') return;
+ localStorage.setItem('vngeoguessr_username', username);
+}
+
+// Get result message based on score
+export function getResultMessage(score, distance) {
+ if (score > 4) return "Excellent! Outstanding guess!";
+ if (score > 2) return "Good job! Nice work!";
+ if (score > 0) return "Not bad! Keep trying!";
+ return "Nice try! Better luck next time!";
+}
+
+// Get random location within city bbox
+export function getRandomCityLocationFromBbox(cityCode) {
+ const bbox = cityBboxes[cityCode];
+ if (!bbox) {
+ throw new Error(`No bbox found for city code: ${cityCode}`);
+ }
+
+ const [minLng, minLat, maxLng, maxLat] = bbox;
+
+ const lng = Math.random() * (maxLng - minLng) + minLng;
+ const lat = Math.random() * (maxLat - minLat) + minLat;
+
+ return { lat, lng };
+}
diff --git a/src/lib/leaderboard.js b/src/lib/leaderboard.js
new file mode 100644
index 0000000..5d59b89
--- /dev/null
+++ b/src/lib/leaderboard.js
@@ -0,0 +1,96 @@
+import { getRedis } from './redis.js';
+
+// Global Vietnam leaderboard constants
+const LEADERBOARD_KEY = 'leaderboard:vietnam';
+const MAX_LEADERBOARD_SIZE = 200;
+
+
+/**
+ * Get the global Vietnam leaderboard
+ * @param {number} limit - Number of entries to return (default: 100)
+ * @returns {Promise
} Array of leaderboard entries
+ */
+export async function getLeaderboard(limit = 100) {
+ try {
+ const redis = await getRedis();
+
+ // Get top entries from the sorted set (highest scores first)
+ const leaderboardData = await redis.zRangeWithScores(LEADERBOARD_KEY, 0, limit - 1, {
+ REV: true // Reverse order to get highest scores first
+ });
+
+ const entries = [];
+
+ // Process each entry (leaderboardData is array of {value, score} objects)
+ for (let i = 0; i < leaderboardData.length; i++) {
+ const entry = leaderboardData[i];
+
+ entries.push({
+ username: entry.value,
+ score: Number(entry.score),
+ rank: i + 1 // Calculate rank based on position
+ });
+ }
+
+ return entries;
+
+ } catch (error) {
+ console.error('Error fetching leaderboard:', error);
+ throw error; // Pass through the original error for better debugging
+ }
+}
+
+/**
+ * Submit a score to the global Vietnam leaderboard
+ * @param {string} username - Player username
+ * @param {number} score - Score achieved (0-5)
+ * @returns {Promise