diff --git a/CLAUDE.md b/CLAUDE.md index 3652fe0..8482e09 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,13 +58,15 @@ src/ Key configuration files in the project root: -- `tsconfig.json` - TypeScript configuration -- `tailwind.config.ts` - Tailwind CSS customization -- `.eslintrc.js` - ESLint with TypeScript rules -- `postcss.config.js` - PostCSS configuration -- `next.config.js` - Next.js configuration -- `jest.config.js` - Jest testing configuration +- `tsconfig.json` - TypeScript configuration with strict settings and path mapping +- `tailwind.config.ts` - Tailwind CSS customization with TypeScript +- `.eslintrc.json` - ESLint with TypeScript rules +- `postcss.config.ts` - PostCSS configuration with TypeScript +- `next.config.ts` - Next.js configuration with TypeScript +- `jest.config.ts` - Jest testing configuration with TypeScript +- `jest.setup.ts` - Jest setup and mocks with TypeScript - `package.json` - Dependencies and scripts +- `next-env.d.ts` - Next.js TypeScript environment types ## 📦 Installation & Setup @@ -236,7 +238,7 @@ This project uses a carefully chosen combination of specialized libraries rather 1. **User Entry**: Home page checks localStorage for cached username, displays popup if not found 2. **City Selection**: User selects from available Vietnamese cities on home page 3. **Game Initialization**: Navigate to game scene with selected city parameter -4. **Image Loading**: Server provides random panoramic image from city bounds via `src/app/api/mapillary/route.js` +4. **Image Loading**: Server provides random panoramic image from city bounds via `src/app/api/mapillary/route.ts` 5. **Gameplay**: - Display raw panoramic image without location metadata - Show city overview map for location guessing @@ -255,12 +257,12 @@ This project uses a carefully chosen combination of specialized libraries rather **Technical Implementation:** -- **Image Fetching**: `src/services/game-image.service.js` handles client-side image fetching with retry logic -- **API Integration**: `src/services/mapillary.service.js` provides server-side Mapillary API integration -- **Display**: `src/components/features/PanoViewer.js` renders raw 360° images using MapillaryJS npm module -- **Interaction**: `src/components/features/GameMap.js` provides MapLibre GL JS-based interactive map for location guessing -- **Scoring**: `src/services/scoring.service.js` handles point calculation with configurable thresholds -- **Geography**: `src/services/geography.service.js` provides distance calculation with OpenStreetMap validation +- **Image Fetching**: `src/services/game-image.service.ts` handles client-side image fetching with retry logic +- **API Integration**: `src/services/mapillary.service.ts` provides server-side Mapillary API integration +- **Display**: `src/components/features/PanoViewer.tsx` renders raw 360° images using MapillaryJS npm module +- **Interaction**: `src/components/features/GameMap.tsx` provides MapLibre GL JS-based interactive map for location guessing +- **Scoring**: `src/services/scoring.service.ts` handles point calculation with configurable thresholds +- **Geography**: `src/services/geography.service.ts` provides distance calculation with OpenStreetMap validation - **Session Management**: Per-user session tracking for concurrent multi-player support ### Key Components @@ -272,7 +274,7 @@ This project uses a carefully chosen combination of specialized libraries rather ### Data Structure -- City boundaries defined in `src/constants/locations.js:LOCATION_BOUNDS` object with coordinates and delta values for API queries +- City boundaries defined in `src/constants/locations.ts:LOCATION_BOUNDS` object with coordinates and delta values for API queries - Location codes: HN (Hanoi), TPHCM (Ho Chi Minh City), HP (Hai Phong), ND (Nam Dinh), DN (Da Nang), DL (Dalat), DHLA (Duc Hoa) - Constants organized with enums: `LOCATION_CODES`, `LOCATION_BOUNDS`, `LOCATION_NAMES` @@ -293,18 +295,21 @@ Game state managed at page level with React hooks: ## Recent Implementation Updates ### Modern Tech Stack Migration (Latest) -- **Language**: TypeScript (`^5.0.0`) with strict typing and interfaces -- **Package Manager**: Migrated from npm to pnpm with updated scripts (dev, build, start, lint, format, test) -- **Tailwind CSS**: Complete migration from custom CSS to Tailwind utilities with custom design tokens matching game theme +- **Language**: TypeScript (`^5.7.3`) with strict typing, interfaces, and complete migration +- **Package Manager**: Migrated from npm to pnpm with updated scripts (dev, build, start, lint, format, test, type-check) +- **Tailwind CSS**: Complete migration from custom CSS to Tailwind utilities with custom design tokens matching game theme - **shadcn/ui**: Installed and configured with utility functions, ready for UI component replacements - **React Query**: Added TanStack Query with QueryClientProvider in layout and custom hooks (`useMapillary`, `useScoring`) - **Testing Setup**: Configured Jest + React Testing Library with specialized mocks for MapLibre/MapillaryJS libraries -- **Project Structure**: Added modern hooks/, lib/ directories following TypeScript architectural guidelines +- **Project Structure**: Added modern hooks/, lib/, types/ directories following TypeScript architectural guidelines - **Development Environment**: Updated to modern toolchain with TypeScript, ESLint, Prettier formatting and enhanced DX +- **File Migration**: All 25+ JavaScript files converted to TypeScript with proper typing +- **Type Safety**: Custom type definitions for external libraries (MapLibre GL JS, MapillaryJS) +- **Zero JavaScript**: Complete elimination of ALL `.js` files from entire project (src/ + root config files) ### Current Architecture Status - ✅ **Framework**: Next.js 15 + React 19 -- ✅ **Language**: TypeScript with strict configuration +- ✅ **Language**: TypeScript with strict configuration - **COMPLETE MIGRATION** - ✅ **Styling**: Tailwind CSS with custom game design tokens - ✅ **Data Fetching**: React Query hooks for API management - ✅ **Testing**: Jest + RTL with TypeScript and library mocks @@ -312,13 +317,16 @@ Game state managed at page level with React hooks: - ✅ **App Router**: All app router files migrated to TypeScript (.tsx) - ✅ **API Routes**: All API routes migrated to TypeScript (.ts) - ✅ **Service Layer**: All service files migrated with proper TypeScript interfaces -- ✅ **Core Components**: GameMap, PanoViewer migrated to TypeScript +- ✅ **Components**: All components migrated to TypeScript (GameMap, PanoViewer, ResultModal, Header, DonateModal, UsernameModal) +- ✅ **Hooks**: All hooks migrated with proper TypeScript return types +- ✅ **Lib Files**: React Query provider and utilities migrated to TypeScript +- ✅ **Constants**: Location constants migrated with TypeScript interfaces and enums - ✅ **Type Definitions**: Custom type definitions created for MapLibre GL JS and MapillaryJS -- 🚧 **UI Components**: Ready for shadcn/ui migration (Header, DonateModal, UsernameModal) -- 🚧 **Constants**: Ready for TypeScript interface migration -- 🚧 **Hooks**: Ready for TypeScript migration with proper return types +- ✅ **Index Files**: All index files migrated and JavaScript files removed +- ✅ **Config Files**: All root configuration files migrated (next.config.ts, jest.config.ts, postcss.config.ts, tailwind.config.ts) +- 🚧 **UI Components**: Ready for shadcn/ui migration (existing components are TypeScript-ready) -Project is now fully migrated to TypeScript with modern tooling and ready for continued development. +**🎉 COMPLETE TYPESCRIPT MIGRATION ACHIEVED** - Zero JavaScript files remaining in entire project. All source code AND configuration files are now TypeScript-enabled with modern tooling and industry-standard development practices. ## 🏗️ Service Layer Architecture diff --git a/README.md b/README.md index b78dc79..0320026 100644 --- a/README.md +++ b/README.md @@ -9,15 +9,15 @@ A Next.js-based Vietnamese geography guessing game where players guess the locat - 🎯 Interactive map for location guessing - 📊 Distance-based scoring system - 🖼️ 360° panoramic street view images using Photo Sphere Viewer -- ⚡ Built with Next.js 14 and JavaScript for performance and simplicity +- ⚡ Built with Next.js 15 and TypeScript for performance and type safety ## Tech Stack -- **Framework**: Next.js 14 with App Router -- **Language**: JavaScript -- **Styling**: CSS Modules with custom styling -- **Maps**: Leaflet.js -- **360° Images**: Photo Sphere Viewer +- **Framework**: Next.js 15 with App Router +- **Language**: TypeScript with strict type checking +- **Styling**: Tailwind CSS with custom design tokens +- **Maps**: MapLibre GL JS +- **360° Images**: MapillaryJS - **Image API**: Mapillary Graph API ## Getting Started @@ -60,24 +60,24 @@ npm start ├── app/ # Next.js app directory │ ├── game/ # Game page │ ├── globals.css # Global styles -│ ├── layout.js # Root layout -│ └── page.js # Home page +│ ├── layout.tsx # Root layout +│ └── page.tsx # Home page ├── components/ # Reusable React components -│ ├── DonateModal.js -│ ├── GameMap.js -│ ├── Header.js -│ ├── PanoViewer.js -│ └── ResultModal.js +│ ├── DonateModal.tsx +│ ├── GameMap.tsx +│ ├── Header.tsx +│ ├── PanoViewer.tsx +│ └── ResultModal.tsx ├── lib/ # Utility functions -│ └── gameUtils.js # Game logic and API calls +│ └── utils.ts # Utility functions └── public/ # Static assets ``` ## Key Improvements from Original -- **Modern Framework**: Migrated from vanilla HTML/JS to Next.js with JavaScript +- **Modern Framework**: Migrated from vanilla HTML/JS to Next.js with TypeScript - **Component-Based Architecture**: Modular, reusable React components -- **Clean Code**: Well-structured JavaScript with proper organization +- **Clean Code**: Well-structured TypeScript with proper type safety and organization - **Code Deduplication**: Eliminated duplicate code and consolidated utilities - **Performance**: Optimized with Next.js built-in optimizations - **Maintainability**: Cleaner code structure and separation of concerns diff --git a/components.json b/components.json index 716ae2d..f4dc904 100644 --- a/components.json +++ b/components.json @@ -2,9 +2,9 @@ "$schema": "https://ui.shadcn.com/schema.json", "style": "default", "rsc": false, - "tsx": false, + "tsx": true, "tailwind": { - "config": "tailwind.config.js", + "config": "tailwind.config.ts", "css": "src/app/globals.css", "baseColor": "slate", "cssVariables": true, diff --git a/jest.config.js b/jest.config.ts similarity index 72% rename from jest.config.js rename to jest.config.ts index db6f8ac..2564aae 100644 --- a/jest.config.js +++ b/jest.config.ts @@ -1,4 +1,5 @@ -const nextJest = require('next/jest') +import nextJest from 'next/jest' +import type { Config } from 'jest' const createJestConfig = nextJest({ // Provide the path to your Next.js app to load next.config.js and .env files @@ -6,8 +7,8 @@ const createJestConfig = nextJest({ }) // Add any custom config to be passed to Jest -const customJestConfig = { - setupFilesAfterEnv: ['/jest.setup.js'], +const customJestConfig: Config = { + setupFilesAfterEnv: ['/jest.setup.ts'], testEnvironment: 'jsdom', testPathIgnorePatterns: ['/node_modules/', '/.next/'], transform: { @@ -19,4 +20,4 @@ const customJestConfig = { } // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async -module.exports = createJestConfig(customJestConfig) \ No newline at end of file +export default createJestConfig(customJestConfig) \ No newline at end of file diff --git a/jest.setup.js b/jest.setup.ts similarity index 92% rename from jest.setup.js rename to jest.setup.ts index 66cd1ff..42a9516 100644 --- a/jest.setup.js +++ b/jest.setup.ts @@ -40,4 +40,7 @@ const localStorageMock = { removeItem: jest.fn(), clear: jest.fn(), } -global.localStorage = localStorageMock \ No newline at end of file + +Object.defineProperty(window, 'localStorage', { + value: localStorageMock +}) \ No newline at end of file diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 8f0b21c..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "compilerOptions": { - "lib": ["dom", "dom.iterable", "es6"], - "allowJs": true, - "skipLibCheck": true, - "strict": false, - "noEmit": true, - "esModuleInterop": true, - "module": "esnext", - "moduleResolution": "bundler", - "resolveJsonModule": true, - "isolatedModules": true, - "jsx": "preserve", - "incremental": true, - "plugins": [ - { - "name": "next" - } - ], - "baseUrl": ".", - "paths": { - "@/*": ["./src/*"] - } - }, - "include": ["src/**/*.js", "src/**/*.jsx"], - "exclude": ["node_modules"] -} diff --git a/next.config.js b/next.config.ts similarity index 74% rename from next.config.js rename to next.config.ts index 989c135..a1cc57f 100644 --- a/next.config.js +++ b/next.config.ts @@ -1,5 +1,6 @@ -/** @type {import('next').NextConfig} */ -const nextConfig = { +import type { NextConfig } from 'next' + +const nextConfig: NextConfig = { images: { domains: ['graph.mapillary.com', 'maps.google.com', 'unpkg.com'], unoptimized: true @@ -15,4 +16,4 @@ const nextConfig = { } } -module.exports = nextConfig \ No newline at end of file +export default nextConfig \ No newline at end of file diff --git a/postcss.config.js b/postcss.config.js deleted file mode 100644 index dc655aa..0000000 --- a/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -module.exports = { - plugins: { - '@tailwindcss/postcss': {}, - autoprefixer: {}, - }, -} \ No newline at end of file diff --git a/postcss.config.ts b/postcss.config.ts new file mode 100644 index 0000000..650a12a --- /dev/null +++ b/postcss.config.ts @@ -0,0 +1,10 @@ +import type { Config } from 'postcss-load-config' + +const config: Config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} + +export default config \ No newline at end of file diff --git a/src/components/features/ResultModal.js b/src/components/features/ResultModal.tsx similarity index 88% rename from src/components/features/ResultModal.js rename to src/components/features/ResultModal.tsx index 78e827e..83125a2 100644 --- a/src/components/features/ResultModal.js +++ b/src/components/features/ResultModal.tsx @@ -6,6 +6,24 @@ import { Viewer } from 'mapillary-js'; import 'maplibre-gl/dist/maplibre-gl.css'; import 'mapillary-js/dist/mapillary.css'; +interface ImageData { + id: string; + url: string; + lat: number; + lng: number; + [key: string]: unknown; +} + +interface ResultModalProps { + isOpen: boolean; + distance: number; + points: number; + trueLocation: [number, number]; + guessLocation: [number, number]; + imageData: ImageData | null; + onNextRound: () => void; +} + export default function ResultModal({ isOpen, distance, @@ -14,12 +32,12 @@ export default function ResultModal({ guessLocation, imageData, onNextRound -}) { - const mapRef = useRef(null); - const mapInstanceRef = useRef(null); - const panoRef = useRef(null); - const viewerRef = useRef(null); - const markersRef = useRef([]); +}: ResultModalProps) { + const mapRef = useRef(null); + const mapInstanceRef = useRef(null); + const panoRef = useRef(null); + const viewerRef = useRef(null); + const markersRef = useRef([]); useEffect(() => { if (!isOpen || !mapRef.current) return; @@ -38,7 +56,7 @@ export default function ResultModal({ // Create MapLibre map mapInstanceRef.current = new maplibregl.Map({ - container: mapRef.current, + container: mapRef.current!, style: { version: 8, sources: { @@ -62,6 +80,8 @@ export default function ResultModal({ }); mapInstanceRef.current.on('load', () => { + if (!mapInstanceRef.current) return; + // Add true location marker (green) mapInstanceRef.current.addSource('true-location', { type: 'geojson', @@ -70,7 +90,8 @@ export default function ResultModal({ geometry: { type: 'Point', coordinates: [trueLng, trueLat] - } + }, + properties: {} } }); @@ -94,7 +115,8 @@ export default function ResultModal({ geometry: { type: 'Point', coordinates: [guessLng, guessLat] - } + }, + properties: {} } }); @@ -118,7 +140,8 @@ export default function ResultModal({ geometry: { type: 'LineString', coordinates: [[trueLng, trueLat], [guessLng, guessLat]] - } + }, + properties: {} } }); @@ -173,7 +196,7 @@ export default function ResultModal({ viewerRef.current = new Viewer({ accessToken: process.env.NEXT_PUBLIC_MAPILLARY_ACCESS_TOKEN || 'MLY|24113623194974280|5bf83fa202912f1cc3210b2cf968fb65', - container: panoRef.current, + container: panoRef.current!, imageId: imageData.id, component: { attribution: false, @@ -217,14 +240,14 @@ export default function ResultModal({ if (!isOpen) return null; - const formatDistance = (dist) => { + const formatDistance = (dist: number): string => { if (dist > 1000) { return `${(dist / 1000).toFixed(2)} KM`; } return `${dist} M`; }; - const getResultMessage = (dist, pts) => { + const getResultMessage = (dist: number, pts: number): string => { if (pts >= 5) return "Perfect! 🎯"; if (pts >= 4) return "Excellent! 🎉"; if (pts >= 3) return "Great job! 👍"; diff --git a/src/components/features/index.js b/src/components/features/index.ts similarity index 100% rename from src/components/features/index.js rename to src/components/features/index.ts diff --git a/src/components/ui/DonateModal.js b/src/components/ui/DonateModal.tsx similarity index 69% rename from src/components/ui/DonateModal.js rename to src/components/ui/DonateModal.tsx index 114c4d1..1b30a3c 100644 --- a/src/components/ui/DonateModal.js +++ b/src/components/ui/DonateModal.tsx @@ -1,11 +1,17 @@ 'use client'; import Image from 'next/image'; +import { MouseEvent } from 'react'; -export default function DonateModal({ isOpen, onClose }) { +interface DonateModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function DonateModal({ isOpen, onClose }: DonateModalProps) { if (!isOpen) return null; - const handleBackdropClick = (e) => { + const handleBackdropClick = (e: MouseEvent) => { if (e.target === e.currentTarget) { onClose(); } diff --git a/src/components/ui/Header.js b/src/components/ui/Header.tsx similarity index 80% rename from src/components/ui/Header.js rename to src/components/ui/Header.tsx index 680994d..3281c54 100644 --- a/src/components/ui/Header.js +++ b/src/components/ui/Header.tsx @@ -1,6 +1,10 @@ 'use client'; -export default function Header({ onDonateClick }) { +interface HeaderProps { + onDonateClick: () => void; +} + +export default function Header({ onDonateClick }: HeaderProps) { return (
@@ -18,4 +22,4 @@ export default function Header({ onDonateClick }) {
); -} +} \ No newline at end of file diff --git a/src/components/ui/UsernameModal.js b/src/components/ui/UsernameModal.tsx similarity index 67% rename from src/components/ui/UsernameModal.js rename to src/components/ui/UsernameModal.tsx index cd3f069..e3a067f 100644 --- a/src/components/ui/UsernameModal.js +++ b/src/components/ui/UsernameModal.tsx @@ -1,17 +1,26 @@ 'use client'; -import { useState } from 'react'; +import { useState, FormEvent, ChangeEvent } from 'react'; -export default function UsernameModal({ isOpen, onSubmit }) { +interface UsernameModalProps { + isOpen: boolean; + onSubmit: (username: string) => void; +} + +export default function UsernameModal({ isOpen, onSubmit }: UsernameModalProps) { const [username, setUsername] = useState(''); - const handleSubmit = (e) => { + const handleSubmit = (e: FormEvent) => { e.preventDefault(); if (username.trim()) { onSubmit(username.trim()); } }; + const handleInputChange = (e: ChangeEvent) => { + setUsername(e.target.value); + }; + if (!isOpen) return null; return ( @@ -24,7 +33,7 @@ export default function UsernameModal({ isOpen, onSubmit }) { setUsername(e.target.value)} + onChange={handleInputChange} placeholder="Your username" className="username-input" maxLength={20} diff --git a/src/components/ui/index.js b/src/components/ui/index.ts similarity index 100% rename from src/components/ui/index.js rename to src/components/ui/index.ts diff --git a/src/constants/locations.js b/src/constants/locations.ts similarity index 64% rename from src/constants/locations.js rename to src/constants/locations.ts index 16268ab..097177f 100644 --- a/src/constants/locations.js +++ b/src/constants/locations.ts @@ -13,13 +13,23 @@ export const LOCATION_CODES = { DA_NANG: 'DN', DALAT: 'DL', DUC_HOA: 'DHLA' -}; +} as const; + +/** + * Location code types + */ +export type LocationCode = typeof LOCATION_CODES[keyof typeof LOCATION_CODES]; + +/** + * Location bounds format: [minLongitude, minLatitude, maxLongitude, maxLatitude, deltaRadius] + */ +export type LocationBounds = [number, number, number, number, number]; /** * Vietnamese city boundaries for the geography guessing game * Format: [minLongitude, minLatitude, maxLongitude, maxLatitude, deltaRadius] */ -export const LOCATION_BOUNDS = { +export const LOCATION_BOUNDS: Record = { [LOCATION_CODES.HANOI]: [105.77, 20.96, 105.88, 21.05, 0.003], [LOCATION_CODES.HO_CHI_MINH]: [106.62, 10.71, 106.75, 10.83, 0.005], [LOCATION_CODES.HAI_PHONG]: [106.65, 20.8, 106.75, 20.9, 0.05], @@ -32,7 +42,7 @@ export const LOCATION_BOUNDS = { /** * Display names for Vietnamese cities */ -export const LOCATION_NAMES = { +export const LOCATION_NAMES: Record = { [LOCATION_CODES.HANOI]: 'HA NOI', [LOCATION_CODES.HO_CHI_MINH]: 'TP. HO CHI MINH', [LOCATION_CODES.HAI_PHONG]: 'HAI PHONG', @@ -44,26 +54,21 @@ export const LOCATION_NAMES = { /** * Get all available location codes - * @returns {string[]} Array of location codes */ -export function getAvailableLocations() { +export function getAvailableLocations(): LocationCode[] { return Object.values(LOCATION_CODES); } /** * Get display name for location code - * @param {string} locationCode - Location code - * @returns {string} Display name or empty string if not found */ -export function getLocationDisplayName(locationCode) { - return LOCATION_NAMES[locationCode] || ''; +export function getLocationDisplayName(locationCode: string): string { + return LOCATION_NAMES[locationCode as LocationCode] || ''; } /** * Get bounds for location code - * @param {string} locationCode - Location code - * @returns {number[]|null} Bounds array or null if not found */ -export function getLocationBounds(locationCode) { - return LOCATION_BOUNDS[locationCode] || null; -} +export function getLocationBounds(locationCode: string): LocationBounds | null { + return LOCATION_BOUNDS[locationCode as LocationCode] || null; +} \ No newline at end of file diff --git a/src/hooks/index.js b/src/hooks/index.js deleted file mode 100644 index d6fddde..0000000 --- a/src/hooks/index.js +++ /dev/null @@ -1,2 +0,0 @@ -export { useMapillaryImage } from './useMapillary'; -export { useScoring } from './useScoring'; \ No newline at end of file diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..757b43a --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1,3 @@ +export { useMapillaryImage } from './useMapillary'; +export { useScoring } from './useScoring'; +export type { ScoreSubmission, ScoreResponse } from './useScoring'; \ No newline at end of file diff --git a/src/hooks/useMapillary.js b/src/hooks/useMapillary.ts similarity index 50% rename from src/hooks/useMapillary.js rename to src/hooks/useMapillary.ts index 1d8fa77..e44a7a1 100644 --- a/src/hooks/useMapillary.js +++ b/src/hooks/useMapillary.ts @@ -1,19 +1,17 @@ -import { useQuery } from '@tanstack/react-query'; -import { fetchRandomGameImage } from '@/services/game-image.service'; +import { useQuery, UseQueryResult } from '@tanstack/react-query'; +import { fetchRandomGameImage, GameImageData } from '@/services/game-image.service'; /** * React Query hook for fetching Mapillary images - * @param {string} locationCode - Location code (e.g., 'HN', 'TPHCM') - * @returns {Object} React Query result with image data */ -export function useMapillaryImage(locationCode) { +export function useMapillaryImage(locationCode: string): UseQueryResult { return useQuery({ queryKey: ['mapillary-image', locationCode], queryFn: () => fetchRandomGameImage(locationCode), enabled: !!locationCode, staleTime: 5 * 60 * 1000, // 5 minutes retry: 3, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000), }); } diff --git a/src/hooks/useScoring.js b/src/hooks/useScoring.js deleted file mode 100644 index 49b7628..0000000 --- a/src/hooks/useScoring.js +++ /dev/null @@ -1,59 +0,0 @@ -import { useMutation } from '@tanstack/react-query'; - -/** - * Scoring data type definition - * @typedef {Object} ScoreSubmission - * @property {string} username - Player username - * @property {number} guessLat - Guessed latitude - * @property {number} guessLng - Guessed longitude - * @property {number} trueLat - Actual latitude - * @property {number} trueLng - Actual longitude - * @property {string} imageId - Mapillary image ID - */ - -/** - * React Query mutation hook for submitting game scores - * @returns {Object} Mutation object with mutate function and status - */ -export function useScoring() { - return useMutation({ - mutationFn: async (scoreData) => { - // Validate score data before sending - if (!isValidScoreData(scoreData)) { - throw new Error('Invalid score data provided'); - } - - const response = await fetch('/api/scoring', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(scoreData), - }); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.error || 'Failed to submit score'); - } - - return response.json(); - }, - retry: 2, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 10000), - }); -} - -/** - * Validates score submission data - * @param {Object} scoreData - Score data to validate - * @returns {boolean} True if data is valid - */ -function isValidScoreData(scoreData) { - return scoreData && - typeof scoreData.username === 'string' && - scoreData.username.trim().length > 0 && - typeof scoreData.guessLat === 'number' && - typeof scoreData.guessLng === 'number' && - typeof scoreData.trueLat === 'number' && - typeof scoreData.trueLng === 'number' && - typeof scoreData.imageId === 'string' && - scoreData.imageId.trim().length > 0; -} \ No newline at end of file diff --git a/src/hooks/useScoring.ts b/src/hooks/useScoring.ts new file mode 100644 index 0000000..2b3cac1 --- /dev/null +++ b/src/hooks/useScoring.ts @@ -0,0 +1,72 @@ +import { useMutation, UseMutationResult } from '@tanstack/react-query'; + +/** + * Scoring data type definition + */ +export interface ScoreSubmission { + username: string; + guessLat: number; + guessLng: number; + trueLat: number; + trueLng: number; + imageId: string; +} + +/** + * Score submission response type + */ +export interface ScoreResponse { + sessionId: string; + distance: number; + points: number; + username: string; +} + +/** + * React Query mutation hook for submitting game scores + */ +export function useScoring(): UseMutationResult { + return useMutation({ + mutationFn: async (scoreData: ScoreSubmission): Promise => { + // Validate score data before sending + if (!isValidScoreData(scoreData)) { + throw new Error('Invalid score data provided'); + } + + const response = await fetch('/api/scoring', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(scoreData), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to submit score'); + } + + return response.json(); + }, + retry: 2, + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 10000), + }); +} + +/** + * Validates score submission data + */ +function isValidScoreData(scoreData: unknown): scoreData is ScoreSubmission { + if (typeof scoreData !== 'object' || scoreData === null) { + return false; + } + + const data = scoreData as Record; + + return typeof data.username === 'string' && + data.username.trim().length > 0 && + typeof data.guessLat === 'number' && + typeof data.guessLng === 'number' && + typeof data.trueLat === 'number' && + typeof data.trueLng === 'number' && + typeof data.imageId === 'string' && + data.imageId.trim().length > 0; +} \ No newline at end of file diff --git a/src/lib/react-query.js b/src/lib/react-query.tsx similarity index 57% rename from src/lib/react-query.js rename to src/lib/react-query.tsx index 58ce66c..e48a827 100644 --- a/src/lib/react-query.js +++ b/src/lib/react-query.tsx @@ -1,15 +1,19 @@ 'use client'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useState, ReactNode } from 'react'; -export default function ReactQueryProvider({ children }) { +interface ReactQueryProviderProps { + children: ReactNode; +} + +export default function ReactQueryProvider({ children }: ReactQueryProviderProps) { const [queryClient] = useState(() => new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, // 5 minutes retry: 2, - retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000), + retryDelay: (attemptIndex: number) => Math.min(1000 * 2 ** attemptIndex, 30000), }, }, })); diff --git a/src/lib/utils.js b/src/lib/utils.js deleted file mode 100644 index c3f5c1c..0000000 --- a/src/lib/utils.js +++ /dev/null @@ -1,6 +0,0 @@ -import { clsx } from "clsx"; -import { twMerge } from "tailwind-merge"; - -export function cn(...inputs) { - return twMerge(clsx(inputs)); -} \ No newline at end of file diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..daab5de --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} \ No newline at end of file diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 0000000..36910e5 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,56 @@ +import type { Config } from 'tailwindcss' + +const config: Config = { + content: [ + './src/pages/**/*.{js,ts,jsx,tsx,mdx}', + './src/components/**/*.{js,ts,jsx,tsx,mdx}', + './src/app/**/*.{js,ts,jsx,tsx,mdx}', + ], + theme: { + extend: { + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: "var(--radius)", + md: "calc(var(--radius) - 2px)", + sm: "calc(var(--radius) - 4px)", + }, + }, + }, + plugins: [], +} + +export default config \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c06f76d..569acc3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "es6"], - "allowJs": true, + "lib": ["dom", "dom.iterable", "ES2022"], + "allowJs": false, "skipLibCheck": true, "strict": true, "noEmit": true, @@ -21,8 +21,10 @@ "paths": { "@/*": ["./src/*"] }, - "target": "ES2017", - "forceConsistentCasingInFileNames": true + "target": "ES2022", + "forceConsistentCasingInFileNames": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true }, "include": [ "next-env.d.ts", @@ -31,6 +33,8 @@ ".next/types/**/*.ts" ], "exclude": [ - "node_modules" + "node_modules", + "**/*.js", + "**/*.jsx" ] } \ No newline at end of file