├── .nvmrc ├── .prettierignore ├── .env.template ├── src ├── app │ ├── globals.css │ ├── favicon.ico │ ├── layout.tsx │ ├── restaurant │ │ └── [restaurantId] │ │ │ ├── page.tsx │ │ │ └── _map.tsx │ ├── _search.tsx │ └── page.tsx ├── services │ └── restaurants │ │ ├── fetch.ts │ │ └── schema.ts ├── lib │ └── map.ts └── components │ └── map │ └── geocoder-control.tsx ├── assets ├── search-bar.png ├── map-of-restaurants.png ├── restaurant-detail.png └── restaurant-preview-card.png ├── postcss.config.js ├── vercel.json ├── .vscode ├── settings.json └── extensions.json ├── next.config.mjs ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── .eslintrc.cjs ├── .prettierrc.mjs ├── package.json ├── scripts └── download-data.js └── README.md /.nvmrc: -------------------------------------------------------------------------------- 1 | v18 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .next/ 2 | pnpm-lock.yaml 3 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN=your_mapbox_access_token_goes_here -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotlas/golden-plate-map/main/src/app/favicon.ico -------------------------------------------------------------------------------- /assets/search-bar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotlas/golden-plate-map/main/assets/search-bar.png -------------------------------------------------------------------------------- /assets/map-of-restaurants.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotlas/golden-plate-map/main/assets/map-of-restaurants.png -------------------------------------------------------------------------------- /assets/restaurant-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotlas/golden-plate-map/main/assets/restaurant-detail.png -------------------------------------------------------------------------------- /assets/restaurant-preview-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotlas/golden-plate-map/main/assets/restaurant-preview-card.png -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://openapi.vercel.sh/vercel.json", 3 | "buildCommand": "pnpm run data:download && pnpm run build" 4 | } 5 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "eslint.rules.customizations": [ 4 | { 5 | "rule": "*", 6 | "severity": "warn" 7 | } 8 | ], 9 | "tailwindCSS.classAttributes": ["class", "className"] 10 | } 11 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: "https", 7 | hostname: "*", 8 | }, 9 | ], 10 | }, 11 | }; 12 | 13 | export default nextConfig; 14 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "esbenp.prettier-vscode", 5 | "mgmcdermott.vscode-language-babel", 6 | "irongeek.vscode-env", 7 | "oderwat.indent-rainbow", 8 | "yoavbls.pretty-ts-errors", 9 | "bradlc.vscode-tailwindcss" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./pages/**/*.{ts,tsx}", 5 | "./components/**/*.{ts,tsx}", 6 | "./app/**/*.{ts,tsx}", 7 | "./src/**/*.{ts,tsx}", 8 | ], 9 | theme: { 10 | extend: {}, 11 | }, 12 | plugins: [], 13 | }; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | 38 | # data 39 | data/ -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noEmit": true, 8 | "esModuleInterop": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "resolveJsonModule": true, 12 | "isolatedModules": true, 13 | "jsx": "preserve", 14 | "incremental": true, 15 | "plugins": [ 16 | { 17 | "name": "next" 18 | } 19 | ], 20 | "paths": { 21 | "@/*": ["./src/*"] 22 | } 23 | }, 24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 25 | "exclude": ["node_modules"] 26 | } 27 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a layout component for the application. 3 | * 4 | * @see RootLayout 5 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#layouts 6 | */ 7 | 8 | import type { Metadata } from "next"; 9 | import type { PropsWithChildren } from "react"; 10 | 11 | import "./globals.css"; 12 | 13 | export const metadata: Metadata = { 14 | title: "Golden Plate Map", 15 | description: "Find the best restaurants in California", 16 | }; 17 | 18 | /** 19 | * This component defines the root layout for the application. 20 | * 21 | * All pages will be wrapped in this layout. 22 | */ 23 | export default async function RootLayout({ 24 | children, 25 | }: Readonly) { 26 | return ( 27 | 28 | 29 | {children} 30 | 31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import("eslint").Linter.Config} */ 2 | const config = { 3 | extends: [ 4 | "eslint:recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "next/core-web-vitals", 7 | "prettier", 8 | ], 9 | env: { 10 | es2022: true, 11 | node: true, 12 | }, 13 | parser: "@typescript-eslint/parser", 14 | plugins: ["@typescript-eslint", "import"], 15 | rules: { 16 | "@typescript-eslint/no-unused-vars": [ 17 | "error", 18 | { argsIgnorePattern: "^_", varsIgnorePattern: "^_" }, 19 | ], 20 | "@typescript-eslint/consistent-type-imports": [ 21 | "warn", 22 | { prefer: "type-imports", fixStyle: "separate-type-imports" }, 23 | ], 24 | }, 25 | ignorePatterns: [ 26 | "**/.eslintrc.cjs", 27 | "**/*.config.js", 28 | "**/*.config.cjs", 29 | ".next", 30 | "dist", 31 | "pnpm-lock.yaml", 32 | ], 33 | reportUnusedDisableDirectives: true, 34 | }; 35 | 36 | module.exports = config; 37 | -------------------------------------------------------------------------------- /.prettierrc.mjs: -------------------------------------------------------------------------------- 1 | /** @typedef {import("prettier").Config} PrettierConfig */ 2 | /** @typedef {import("prettier-plugin-tailwindcss").PluginOptions} TailwindConfig */ 3 | /** @typedef {import("@ianvs/prettier-plugin-sort-imports").PluginConfig} SortImportsConfig */ 4 | 5 | /** @type { PrettierConfig | SortImportsConfig | TailwindConfig } */ 6 | const config = { 7 | arrowParens: "always", 8 | printWidth: 80, 9 | singleQuote: false, 10 | jsxSingleQuote: false, 11 | semi: true, 12 | trailingComma: "all", 13 | tabWidth: 2, 14 | plugins: [ 15 | "@ianvs/prettier-plugin-sort-imports", 16 | "prettier-plugin-tailwindcss", 17 | ], 18 | importOrder: [ 19 | "", 20 | "", 21 | "", 22 | "", 23 | "^@/(.*)$", 24 | "", 25 | "^~icons/(.*)$", 26 | "", 27 | ".css$", 28 | ], 29 | importOrderParserPlugins: ["typescript", "jsx", "decorators-legacy"], 30 | importOrderTypeScriptVersion: "5.0.0", 31 | }; 32 | 33 | export default config; 34 | -------------------------------------------------------------------------------- /src/app/restaurant/[restaurantId]/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a page component for a restaurant page. 3 | * 4 | * @see RestaurantPage 5 | */ 6 | 7 | import { RestaurantDetailMap } from "@/app/restaurant/[restaurantId]/_map"; 8 | import { getRestaurantById } from "@/services/restaurants/fetch"; 9 | 10 | type RestaurantPageParams = { 11 | restaurantId: string; 12 | }; 13 | 14 | type RestaurantPageProps = { 15 | params: RestaurantPageParams; 16 | }; 17 | 18 | /** 19 | * The component for a restaurant page. 20 | * 21 | * This is rendered when the user navigates to a restaurant page, `/restaurant/[restaurantId]`. 22 | */ 23 | export default async function RestaurantPage({ params }: RestaurantPageProps) { 24 | const restaurant = await getRestaurantById(parseInt(params.restaurantId, 10)); 25 | 26 | if (!restaurant) { 27 | return
Restaurant not found
; 28 | } 29 | 30 | return ( 31 |
32 |

33 | Restaurant ID: {params.restaurantId} 34 |

35 | 36 |
{JSON.stringify(restaurant, null, 2)}
37 | 38 | 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/_search.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a Client component for the search input. 3 | * 4 | * @see Search 5 | * @see https://nextjs.org/docs/app/building-your-application/rendering/client-components 6 | */ 7 | 8 | "use client"; 9 | 10 | import { useRouter, useSearchParams } from "next/navigation"; 11 | import { useEffect, useState, type ChangeEvent } from "react"; 12 | 13 | /** 14 | * A search input component. 15 | */ 16 | export function Search() { 17 | const searchParams = useSearchParams(); 18 | const router = useRouter(); 19 | 20 | // Keep track of the user's search query 21 | // Initialize the query with the value from the URL search parameters 22 | const [query, setQuery] = useState(searchParams.get("query") || ""); 23 | 24 | // Sync the value of the search input with the query state 25 | const handleQueryChange = (event: ChangeEvent) => { 26 | setQuery(event.target.value); 27 | }; 28 | 29 | // Update the URL search parameters when the query changes 30 | useEffect(() => { 31 | router.push(`?query=${query}`); 32 | }, [query, router]); 33 | 34 | return ( 35 |
36 | 45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/services/restaurants/fetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines methods for fetching restaurant data. 3 | * 4 | * @see getRestaurantById 5 | * @see searchRestaurants 6 | */ 7 | 8 | import { z } from "zod"; 9 | 10 | import data from "@/data/restaurants.json"; 11 | import type { Restaurant } from "@/services/restaurants/schema"; 12 | 13 | const restaurants = data as Restaurant[]; 14 | 15 | /** 16 | * Fetches the first restaurant with the given ID. 17 | */ 18 | export async function getRestaurantById( 19 | id: number, 20 | ): Promise { 21 | return ( 22 | restaurants.find((restaurant) => restaurant.restaurant_id === id) || null 23 | ); 24 | } 25 | 26 | const searchQueryInputSchema = z.object({ 27 | query: z.string().default("").optional(), 28 | limit: z.number().default(10).optional(), 29 | }); 30 | 31 | /** 32 | * Input parameters for searching restaurants. 33 | */ 34 | export type SearchQueryInput = z.infer; 35 | 36 | /** 37 | * Searches for restaurants based on the given query. 38 | */ 39 | export async function searchRestaurants( 40 | input: SearchQueryInput, 41 | ): Promise { 42 | const { query, limit } = searchQueryInputSchema.parse(input); 43 | 44 | console.log( 45 | "Searching for restaurants with query:", 46 | query, 47 | "and limit:", 48 | limit, 49 | ); 50 | console.error("Not implemented"); 51 | 52 | return []; 53 | } 54 | -------------------------------------------------------------------------------- /src/lib/map.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines utilities for working with maps. 3 | * 4 | * @see useZoomToMarkers 5 | */ 6 | 7 | import bbox from "@turf/bbox"; 8 | import { points } from "@turf/helpers"; 9 | import { useCallback, useMemo, type RefObject } from "react"; 10 | import { useMap, type MapRef } from "react-map-gl"; 11 | 12 | type ZoomToMarkersProps = { 13 | markers: { latitude: number; longitude: number }[]; 14 | mapRef?: RefObject; 15 | options?: mapboxgl.FitBoundsOptions; 16 | eventData?: mapboxgl.EventData; 17 | }; 18 | 19 | /** 20 | * A React hook that returns a callback which when called will zoom the map to fit all the given markers. 21 | */ 22 | export function useZoomToMarkers({ 23 | markers, 24 | mapRef, 25 | options, 26 | eventData, 27 | }: ZoomToMarkersProps) { 28 | const { current: map } = useMap(); 29 | 30 | const mapToUse = useMemo(() => { 31 | return mapRef?.current ?? map ?? null; 32 | // eslint-disable-next-line react-hooks/exhaustive-deps 33 | }, [map, mapRef?.current]); 34 | 35 | return useCallback(() => { 36 | if (!mapToUse || markers.length === 0) return; 37 | 38 | const features = points( 39 | markers.map(({ latitude, longitude }) => [longitude, latitude]), 40 | ); 41 | 42 | const bounds = bbox(features) as [number, number, number, number]; 43 | 44 | mapToUse.fitBounds(bounds, options, eventData); 45 | }, [mapToUse, markers, options, eventData]); 46 | } 47 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a page component for the home page. 3 | * 4 | * @see HomePage 5 | * 6 | * @see https://nextjs.org/docs/app/building-your-application/routing/defining-routes 7 | * @see https://nextjs.org/docs/app/building-your-application/routing/pages-and-layouts#pages 8 | * 9 | * It uses React Server Components to fetch and render data on the server. 10 | * 11 | * @see https://nextjs.org/docs/app/building-your-application/rendering/server-components 12 | */ 13 | 14 | import Link from "next/link"; 15 | 16 | import { Search } from "@/app/_search"; 17 | import { 18 | searchRestaurants, 19 | type SearchQueryInput, 20 | } from "@/services/restaurants/fetch"; 21 | 22 | type HomePageProps = { 23 | searchParams: SearchQueryInput; 24 | }; 25 | 26 | /** 27 | * The component for the home page. 28 | * 29 | * This is rendered when the user navigates to the root URL, `/`. 30 | * 31 | */ 32 | export default async function HomePage({ searchParams }: HomePageProps) { 33 | // Use the search parameters in the URL to search for restaurants 34 | const searchResults = await searchRestaurants(searchParams); 35 | 36 | return ( 37 |
38 |

Golden Plate Map

39 |

Find the best restaurants in California

40 | 41 | 42 |

Search results:

43 |
44 |         {JSON.stringify(searchResults, null, 2)}
45 |       
46 | 47 | 48 | Example restaurant page 49 | 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "golden-plate-map", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint", 10 | "format": "prettier --write .", 11 | "data:download": "node scripts/download-data.js" 12 | }, 13 | "dependencies": { 14 | "@mapbox/mapbox-gl-geocoder": "^5.0.2", 15 | "@turf/bbox": "7.0.0-alpha.114", 16 | "@turf/helpers": "7.0.0-alpha.114", 17 | "mapbox-gl": "^3.2.0", 18 | "next": "14.2.0-canary.42", 19 | "react": "^18", 20 | "react-dom": "^18", 21 | "react-map-gl": "^7.1.7", 22 | "react-map-gl-geocoder": "^2.2.0" 23 | }, 24 | "devDependencies": { 25 | "@ianvs/prettier-plugin-sort-imports": "^4.1.1", 26 | "@types/eslint": "^8.56.5", 27 | "@types/mapbox__mapbox-gl-geocoder": "^5.0.0", 28 | "@types/node": "^20", 29 | "@types/react": "^18", 30 | "@types/react-dom": "^18", 31 | "@typescript-eslint/eslint-plugin": "^7.1.1", 32 | "@typescript-eslint/parser": "^7.1.1", 33 | "autoprefixer": "^10.0.1", 34 | "axios": "^1.6.8", 35 | "cli-progress": "^3.12.0", 36 | "eslint": "^8", 37 | "eslint-config-next": "14.1.0", 38 | "eslint-config-prettier": "^9.1.0", 39 | "eslint-plugin-hooks": "^0.4.3", 40 | "eslint-plugin-import": "^2.29.1", 41 | "eslint-plugin-jsx-a11y": "^6.8.0", 42 | "eslint-plugin-react": "^7.34.0", 43 | "prettier": "^3.2.5", 44 | "prettier-plugin-tailwindcss": "^0.5.13", 45 | "postcss": "^8", 46 | "tailwindcss": "^3.4.3", 47 | "typescript": "^5", 48 | "zod": "^3.22.4" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/restaurant/[restaurantId]/_map.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a component for map showing a restaurant's location. 3 | * 4 | * @see RestaurantDetailMap 5 | * @see https://visgl.github.io/react-map-gl/ 6 | */ 7 | 8 | "use client"; 9 | 10 | import { useMemo, useRef } from "react"; 11 | import Map, { 12 | GeolocateControl, 13 | Marker, 14 | NavigationControl, 15 | type MapRef, 16 | } from "react-map-gl"; 17 | 18 | import GeocoderControl from "@/components/map/geocoder-control"; 19 | 20 | import "mapbox-gl/dist/mapbox-gl.css"; 21 | 22 | type RestaurantDetailMapProps = { 23 | latitude: number; 24 | longitude: number; 25 | }; 26 | 27 | /** 28 | * A component for a map showing a restaurant's location. 29 | */ 30 | export function RestaurantDetailMap({ 31 | latitude, 32 | longitude, 33 | }: RestaurantDetailMapProps) { 34 | const markers = useMemo(() => { 35 | return [ 36 | { 37 | latitude, 38 | longitude, 39 | }, 40 | ]; 41 | }, [latitude, longitude]); 42 | 43 | const mapRef = useRef(null); 44 | 45 | return ( 46 | 58 | {/* Map controls */} 59 | 66 | 67 | 68 | 69 | {/* Markers */} 70 | {markers.map((marker, index) => ( 71 | 76 | ))} 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /scripts/download-data.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const fs = require("fs"); 3 | const axios = require("axios"); 4 | const cliProgress = require("cli-progress"); 5 | const path = require("path"); 6 | 7 | const restaurantsDataUrl = 8 | "https://dotlas-marketing.s3.amazonaws.com/interviews/california_restaurants_2024.json"; 9 | const dataSavePath = path.join(process.cwd(), "src", "data"); 10 | const restaurantsDataSavePath = path.join(dataSavePath, "restaurants.json"); 11 | 12 | async function downloadRestaurantsData() { 13 | try { 14 | const response = await axios.get(restaurantsDataUrl, { 15 | responseType: "stream", 16 | }); 17 | 18 | if (response.status !== 200) { 19 | throw new Error(`Failed to download data: ${response.statusText}`); 20 | } 21 | 22 | const totalBytes = parseInt(response.headers["content-length"], 10); 23 | const progressBar = new cliProgress.SingleBar({ 24 | format: "Downloading restaurants data | {bar} | {percentage}%", 25 | barCompleteChar: "\u2588", 26 | barIncompleteChar: "\u2591", 27 | hideCursor: true, 28 | }); 29 | 30 | progressBar.start(totalBytes, 0); 31 | 32 | // Create the folder if it doesn't exist 33 | if (!fs.existsSync(dataSavePath)) { 34 | fs.mkdirSync(dataSavePath); 35 | } 36 | 37 | const writeStream = fs.createWriteStream(restaurantsDataSavePath); 38 | let downloadedBytes = 0; 39 | 40 | writeStream.on("drain", () => { 41 | progressBar.update(downloadedBytes); 42 | }); 43 | 44 | writeStream.on("finish", () => { 45 | progressBar.stop(); 46 | console.log("Restaurants data downloaded successfully!"); 47 | }); 48 | 49 | writeStream.on("error", (error) => { 50 | progressBar.stop(); 51 | console.error("Error downloading data:", error); 52 | }); 53 | 54 | response.data.pipe(writeStream); 55 | response.data.on("data", (chunk) => { 56 | downloadedBytes += chunk.length; 57 | }); 58 | } catch (error) { 59 | console.error("Error downloading data:", error); 60 | } 61 | } 62 | 63 | downloadRestaurantsData(); 64 | -------------------------------------------------------------------------------- /src/components/map/geocoder-control.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This module defines a geocoder control for Mapbox. 3 | * 4 | * @see GeocoderControl 5 | * @see https://github.com/visgl/react-map-gl/blob/master/examples/geocoder/src/geocoder-control.tsx 6 | */ 7 | 8 | import MapboxGeocoder, { 9 | type GeocoderOptions, 10 | } from "@mapbox/mapbox-gl-geocoder"; 11 | import { useState } from "react"; 12 | import { 13 | Marker, 14 | useControl, 15 | type ControlPosition, 16 | type MarkerProps, 17 | } from "react-map-gl"; 18 | 19 | import "@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css"; 20 | 21 | type GeocoderControlProps = Omit< 22 | GeocoderOptions, 23 | "accessToken" | "mapboxgl" | "marker" 24 | > & { 25 | mapboxAccessToken: string; 26 | marker?: boolean | Omit; 27 | 28 | position: ControlPosition; 29 | 30 | onLoading?: (e: object) => void; 31 | onResults?: (e: object) => void; 32 | onResult?: (e: object) => void; 33 | onError?: (e: object) => void; 34 | }; 35 | 36 | /** 37 | * A component that renders a geocoder control. 38 | * It allows the user to search for places and navigate to them. 39 | */ 40 | export default function GeocoderControl({ 41 | marker: markerProp = true, 42 | 43 | onLoading = () => {}, 44 | onResults = () => {}, 45 | onResult = () => {}, 46 | onError = () => {}, 47 | ...props 48 | }: GeocoderControlProps) { 49 | const [marker, setMarker] = useState(null); 50 | 51 | const geocoder = useControl( 52 | () => { 53 | const ctrl = new MapboxGeocoder({ 54 | ...props, 55 | marker: false, 56 | accessToken: props.mapboxAccessToken, 57 | }); 58 | ctrl.on("loading", onLoading); 59 | ctrl.on("results", onResults); 60 | ctrl.on("result", (evt) => { 61 | onResult(evt); 62 | 63 | const { result } = evt; 64 | const location = 65 | result && 66 | (result.center || 67 | (result.geometry?.type === "Point" && result.geometry.coordinates)); 68 | if (location && markerProp) { 69 | setMarker( 70 | , 77 | ); 78 | } else { 79 | setMarker(null); 80 | } 81 | }); 82 | ctrl.on("error", onError); 83 | return ctrl; 84 | }, 85 | { 86 | position: props.position, 87 | }, 88 | ); 89 | 90 | // @ts-expect-error (TS2339) private member 91 | if (geocoder._map) { 92 | if ( 93 | geocoder.getProximity() !== props.proximity && 94 | props.proximity !== undefined 95 | ) { 96 | geocoder.setProximity(props.proximity); 97 | } 98 | if ( 99 | geocoder.getRenderFunction() !== props.render && 100 | props.render !== undefined 101 | ) { 102 | geocoder.setRenderFunction(props.render); 103 | } 104 | if ( 105 | geocoder.getLanguage() !== props.language && 106 | props.language !== undefined 107 | ) { 108 | geocoder.setLanguage(props.language); 109 | } 110 | if (geocoder.getZoom() !== props.zoom && props.zoom !== undefined) { 111 | geocoder.setZoom(props.zoom); 112 | } 113 | if (geocoder.getFlyTo() !== props.flyTo && props.flyTo !== undefined) { 114 | geocoder.setFlyTo(props.flyTo); 115 | } 116 | if ( 117 | geocoder.getPlaceholder() !== props.placeholder && 118 | props.placeholder !== undefined 119 | ) { 120 | geocoder.setPlaceholder(props.placeholder); 121 | } 122 | if ( 123 | geocoder.getCountries() !== props.countries && 124 | props.countries !== undefined 125 | ) { 126 | geocoder.setCountries(props.countries); 127 | } 128 | if (geocoder.getTypes() !== props.types && props.types !== undefined) { 129 | geocoder.setTypes(props.types); 130 | } 131 | if ( 132 | geocoder.getMinLength() !== props.minLength && 133 | props.minLength !== undefined 134 | ) { 135 | geocoder.setMinLength(props.minLength); 136 | } 137 | if (geocoder.getLimit() !== props.limit && props.limit !== undefined) { 138 | geocoder.setLimit(props.limit); 139 | } 140 | if (geocoder.getFilter() !== props.filter && props.filter !== undefined) { 141 | geocoder.setFilter(props.filter); 142 | } 143 | if (geocoder.getOrigin() !== props.origin && props.origin !== undefined) { 144 | geocoder.setOrigin(props.origin); 145 | } 146 | // Types missing from @types/mapbox__mapbox-gl-geocoder 147 | if ( 148 | geocoder.getAutocomplete() !== props.autocomplete && 149 | props.autocomplete !== undefined 150 | ) { 151 | geocoder.setAutocomplete(props.autocomplete); 152 | } 153 | if ( 154 | geocoder.getFuzzyMatch() !== props.fuzzyMatch && 155 | props.fuzzyMatch !== undefined 156 | ) { 157 | geocoder.setFuzzyMatch(props.fuzzyMatch); 158 | } 159 | if ( 160 | geocoder.getRouting() !== props.routing && 161 | props.routing !== undefined 162 | ) { 163 | geocoder.setRouting(props.routing); 164 | } 165 | if ( 166 | geocoder.getWorldview() !== props.worldview && 167 | props.worldview !== undefined 168 | ) { 169 | geocoder.setWorldview(props.worldview); 170 | } 171 | } 172 | return marker; 173 | } 174 | -------------------------------------------------------------------------------- /src/services/restaurants/schema.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This module contains the schema for the restaurant data. 3 | * 4 | * It is defined using the Zod library. 5 | * 6 | * @see https://zod.dev/ 7 | * @see Restaurant 8 | * @see restaurantSchema 9 | */ 10 | 11 | import { z } from "zod"; 12 | 13 | const diningStyles = [ 14 | "Casual Dining", 15 | "Casual Elegant", 16 | "Elegant Dining", 17 | "Fine Dining", 18 | "Home Style", 19 | ] as const; 20 | 21 | const dressCodes = [ 22 | "Business Casual", 23 | "Casual Dress", 24 | "Formal Attire", 25 | "Jacket Preferred", 26 | "Jacket Required", 27 | "Resort Casual", 28 | "Smart Casual", 29 | ] as const; 30 | 31 | const parkingInfos = [ 32 | "Hotel Parking", 33 | "None", 34 | "Private Lot", 35 | "Public Lot", 36 | "Street Parking", 37 | "Valet", 38 | ] as const; 39 | 40 | const paymentOptions = [ 41 | "AMEX", 42 | "Carte Blanche", 43 | "Cash not accepted", 44 | "Cash Only", 45 | "Cheque Gourmet", 46 | "Contactless Payment", 47 | "Diners Club", 48 | "Discover", 49 | "Eurocheque Card", 50 | "JCB", 51 | "MasterCard", 52 | "Mastercard", 53 | "Pay with OpenTable", 54 | "Sodexo Pass", 55 | "Visa", 56 | ] as const; 57 | 58 | const priceRanges = ["$30 and under", "$31 to $50", "$50 and over"] as const; 59 | 60 | const reviewTopics = [ 61 | "Charming", 62 | "Dog-friendly", 63 | "Fancy", 64 | "Gluten-free-friendly", 65 | "Good for business meals", 66 | "Good for groups", 67 | "Good for special occasions", 68 | "Great for brunch", 69 | "Great for craft beers", 70 | "Great for creative cocktails", 71 | "Great for fine wines", 72 | "Great for happy hour", 73 | "Great for live music", 74 | "Great for outdoor dining", 75 | "Great for scenic views", 76 | "Healthy", 77 | "Hot spot", 78 | "Innovative", 79 | "Kid-friendly", 80 | "Lively", 81 | "Neighborhood gem", 82 | "Outstanding value", 83 | "Romantic", 84 | "Vegan-friendly", 85 | "Vegetarian-friendly", 86 | ] as const; 87 | 88 | const tags = [ 89 | "BYO Liquor", 90 | "BYO Wine", 91 | "Banquet", 92 | "Bar Dining", 93 | "Bar/Lounge", 94 | "Beer", 95 | "Beer Garden", 96 | "Cafe", 97 | "Chef's Table", 98 | "Cigar Room", 99 | "Cocktails", 100 | "Corkage Fee", 101 | "Counter Seating", 102 | "Dancing", 103 | "Delivery", 104 | "Dog Friendly", 105 | "Entertainment", 106 | "Farm to Table", 107 | "Full Bar", 108 | ] as const; 109 | 110 | const deliveryPartners = ["CHOW_NOW", "POSTMATES", "UBER_EATS"] as const; 111 | 112 | /** 113 | * The schema for a single restaurant. 114 | */ 115 | const restaurantSchema = z.object({ 116 | country: z.literal("United States"), 117 | Subregion: z.literal("California"), 118 | city: z.string(), 119 | brand_name: z.string(), 120 | categories: z.string().array(), 121 | latitude: z.number(), 122 | longitude: z.number(), 123 | area: z.string(), 124 | address: z.string(), 125 | description: z.string(), 126 | public_transit: z.string().nullable(), 127 | cross_street: z.string().nullable(), 128 | restaurant_website: z.string().nullable(), 129 | phone_number: z.string(), 130 | dining_style: z.enum(diningStyles), 131 | executive_chef_name: z.string().nullable(), 132 | parking_info: z.enum(parkingInfos), 133 | dress_code: z.enum(dressCodes), 134 | entertainment: z.string().nullable(), 135 | operating_hours: z.string(), 136 | price_range_id: z.number(), 137 | price_range: z.enum(priceRanges), 138 | payment_options: z.enum(paymentOptions).array(), 139 | maximum_days_advance_for_reservation: z.number(), 140 | rating: z.number(), 141 | rating_count: z.number(), 142 | atmosphere_rating: z.number(), 143 | noise_rating: z.number(), 144 | food_rating: z.number(), 145 | service_rating: z.number(), 146 | value_rating: z.number(), 147 | terrible_review_count: z.number(), 148 | poor_review_count: z.number(), 149 | average_review_count: z.number(), 150 | very_good_review_count: z.number(), 151 | excellent_review_count: z.number(), 152 | review_count: z.number(), 153 | review_topics: z.enum(reviewTopics).array(), 154 | tags: z.enum(tags).array(), 155 | has_clean_menus: z.boolean(), 156 | has_common_area_cleaning: z.boolean(), 157 | has_common_area_distancing: z.boolean(), 158 | has_contact_tracing_collected: z.boolean(), 159 | has_contactless_payment: z.boolean(), 160 | requires_diner_temperature_check: z.boolean(), 161 | has_limited_seating: z.boolean(), 162 | prohibits_sick_staff: z.boolean(), 163 | has_proof_of_vaccination_outdoor: z.boolean(), 164 | requires_proof_of_vaccination: z.boolean(), 165 | requires_diner_masks: z.boolean(), 166 | requires_wait_staff_masks: z.boolean(), 167 | has_sanitized_surfaces: z.boolean(), 168 | provides_sanitizer_for_customers: z.boolean(), 169 | has_sealed_utensils: z.boolean(), 170 | has_vaccinated_staff: z.boolean(), 171 | requires_staff_temp_checks: z.boolean(), 172 | has_table_layout_with_extra_space: z.boolean(), 173 | is_permanently_closed: z.boolean(), 174 | is_waitlist_only: z.boolean(), 175 | has_waitlist: z.boolean(), 176 | has_bar: z.boolean(), 177 | has_counter: z.boolean(), 178 | has_high_top_seating: z.boolean(), 179 | has_outdoor_seating: z.boolean(), 180 | has_priority_seating: z.boolean(), 181 | has_private_dining: z.boolean(), 182 | has_takeout: z.boolean(), 183 | has_delivery_partners: z.boolean(), 184 | has_pickup: z.boolean(), 185 | has_gifting: z.boolean(), 186 | delivery_partners: z.enum(deliveryPartners).array(), 187 | facebook: z.string().nullable(), 188 | menu_url: z.string().nullable(), 189 | daily_reservation_count: z.number().nullable(), 190 | meal_cost: z.number(), 191 | restaurant_id: z.number(), 192 | }); 193 | 194 | /** 195 | * A single restaurant. 196 | */ 197 | export type Restaurant = z.infer; 198 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🍽️ Golden Plate Map 2 | 3 | Welcome to the Golden Plate Map project! 4 | 5 | Your task is to build a **web application** that allows users to **explore restaurants in California**. 6 | 7 | - [🍽️ Golden Plate Map](#️-golden-plate-map) 8 | - [Getting Started](#getting-started) 9 | - [Pre-requisites](#pre-requisites) 10 | - [Create a Private fork](#create-a-private-fork) 11 | - [Install Dependencies](#install-dependencies) 12 | - [Download the Dataset](#download-the-dataset) 13 | - [Load Environment Variables](#load-environment-variables) 14 | - [Start the Development Server](#start-the-development-server) 15 | - [Codebase](#codebase) 16 | - [Goals](#goals) 17 | - [Home Page (`/`)](#home-page-) 18 | - [Restaurant Page (`/restaurant/[restaurantId]`)](#restaurant-page-restaurantrestaurantid) 19 | - [Miscellaneous](#miscellaneous) 20 | - [Guidelines](#guidelines) 21 | - [Evaluation](#evaluation) 22 | - [Criteria](#criteria) 23 | - [Submission](#submission) 24 | - [Help](#help) 25 | - [GitHub Issues](#github-issues) 26 | - [Contact](#contact) 27 | 28 | ## Getting Started 29 | 30 | ### Pre-requisites 31 | 32 | - [![Node.js]](https://nodejs.org/en/download) `v18` on your development machine. 33 | - The [![pnpm]](https://pnpm.io/) package manager. 34 | - A [![GitHub]](https://github.com) account. 35 | - A [![Vercel]](https://vercel.com/home) account. 36 | 37 | ### Create a Private fork 38 | 39 | Create a private fork of this repository: 40 | 41 | - [Go to the "Import a repository" page on GitHub](https://github.com/new/import) 42 | - Specify the url for this repository 43 | - Click on the "Begin import" button 44 | 45 | Once the repository is created on GitHub, clone it onto your local system! 46 | 47 | ### Install Dependencies 48 | 49 | To install all necessary dependencies, run: 50 | 51 | ```bash 52 | pnpm install 53 | ``` 54 | 55 | ### Download the Dataset 56 | 57 | Use the following command to download the dataset of restaurants in California: 58 | 59 | ```bash 60 | pnpm data:download 61 | ``` 62 | 63 | ### Load Environment Variables 64 | 65 | Create a `.env` file based on the [template](./.env.template) and fill in the necessary values. 66 | 67 | ### Start the Development Server 68 | 69 | Begin interacting with the app by starting the development server: 70 | 71 | ```bash 72 | pnpm dev 73 | ``` 74 | 75 | If everything is set up correctly, you should see a prompt to visit `http://localhost:3000` in your browser. 76 | 77 | > **Note** 78 | > The development server is configured to automatically reload the application when changes are made to the codebase. 79 | > As such, it can be a bit slow when using the app. 80 | > 81 | > To experience a much more performant version of the app, you can build the app using the following commands: 82 | > 83 | > ```bash 84 | > pnpm build 85 | > pnpm start 86 | > ``` 87 | 88 | ## Codebase 89 | 90 | This codebase serves as a starter template for the app. 91 | It is built using the following technologies: 92 | 93 | - [![Next.js]](https://nextjs.org) - a powerful app framework for the web 94 | - [![React]](https://react.dev) - a JavaScript library for building user interfaces 95 | - [![Tailwind]](https://tailwindcss.com) - a utility-first CSS framework for styling 96 | 97 | ## Goals 98 | 99 | Your primary goal is to create a web application that allows users to explore restaurants in California. 100 | 101 | ### Home Page (`/`) 102 | 103 | The primary goal of the [home page](./src/app/page.tsx) is to provide users with the ability to find restaurants. 104 | 105 | It should include the following features: 106 | 107 | - [A search input](./src/app/_search.tsx) to find restaurants by name or cuisine: 108 | 109 | ![Search Input](./assets/search-bar.png) 110 | 111 | - Based on the results returned by the search, a list of preview cards for the restaurants. 112 | 113 | - When no search query is provided, the list should display the top restaurants in California, determined by you. 114 | - Each card must contain the following details for each restaurant: 115 | - Name 116 | - Cuisine 117 | - Rating & Review count 118 | - Location / Area 119 | - Price range 120 | - Interacting with the card must allow the user to navigate to the restaurant's page. 121 | 122 |

123 | Example of a restaurant preview card on UberEats 124 |

Example of a restaurant preview card on UberEats

125 |

126 | 127 | > **Note** 128 | > Do not imitate the above design from UberEats. You are free to design the preview card as you see fit. 129 | 130 | - Display a map with markers for each restaurant returned by the search. 131 | 132 |

133 | A map of restaurants in San Francisco, California on Google Maps 134 |

A map of restaurants in San Francisco, California on Google Maps

135 |

136 | 137 | > **Note** 138 | > Do not imitate the above design from Google Maps. You are free to design the map as you see fit. 139 | 140 | - Clicking on a marker should zoom in on the restaurant's location and highlight/bring into view the preview card for that restaurant. (**BONUS**) 141 | 142 | ### Restaurant Page (`/restaurant/[restaurantId]`) 143 | 144 | This page serves as [the detailed view for a specific restaurant](./src/app/restaurant/[restaurantId]/page.tsx). 145 | 146 | Include as many relevant details from the data set as possible, such as: 147 | 148 | - Address 149 | - Description 150 | - Social Media 151 | - Contact information 152 | - Amenities 153 | - Operating hours 154 | 155 |

156 | An example restaurant page on OpenTable 157 |

An example restaurant page on OpenTable

158 |

159 | 160 | > **Note** 161 | > Do not imitate the above design from OpenTable. You are free to design the restaurant page as you see fit. 162 | 163 | ### Miscellaneous 164 | 165 | - An "I'm feeling lucky" button that randomly selects a restaurant for the user. 166 | - A logo for the application. (**BONUS**) 167 | 168 | ## Guidelines 169 | 170 | - You are free to use any libraries or tools that you are comfortable with, as long as they are compatible with the template's initial stack. 171 | - The following UI component libraries (all compatible with Tailwind) are highly recommended for quick prototyping and development: 172 | - [`shadcn/ui`](https://ui.shadcn.com/) 173 | - [`headlessui`](https://headlessui.com/) 174 | - [`daisyUI`](https://daisyui.com/) 175 | - [`flowbite`](https://flowbite.com/) 176 | - and any other libraries you find useful. 177 | - It is highly recommended to use the [![mapbox]](https://www.mapbox.com) package for maps. 178 | 179 | A React compatible library is already included in the project dependencies. Find more information on how use it [here](https://visgl.github.io/.react-map-gl/). 180 | 181 | You will need [an access token](https://visgl.github.io/react-map-gl/docs/get-started/mapbox-tokens) from Mapbox to use the library. 182 | 183 | Once you have an access token, set the `NEXT_PUBLIC_MAPBOX_ACCESS_TOKEN` environment variable in the `.env` file you created earlier. 184 | 185 | > **Note** 186 | > This `.env` file is already included in the `.gitignore` file, so you don't have to worry about accidentally committing it to the repository. 187 | 188 | - If you are using VS Code as your text editor, consider installing the [recommended extensions](.vscode/extensions.json) for this project. 189 | - Always keep your code well formatted. Your IDE should be configured to automatically format your code using [![Prettier]](https://prettier.io) but you can also run the following command: 190 | ```bash 191 | pnpm format 192 | ``` 193 | - Ensure that your code is free from common programming style issues by running the following command: 194 | ```bash 195 | pnpm lint 196 | ``` 197 | This command runs [![ESLint]](https://eslint.org) on the codebase to catch any issues. 198 | Your IDE should also be configured to show ESLint warnings and errors. 199 | - It is highly recommended to use services like GitHub Copilot, ChatGPT, etc., to speed up your development process. 200 | 201 | ## Evaluation 202 | 203 | ### Criteria 204 | 205 | For the application: 206 | 207 | - **Functionality**: Does the application meet the requirements/goals? 208 | - **Familiarity**: Is the UI intuitive and easy to use? Is the application accessible? 209 | - **Performance**: Does the application perform well? Are there any interactions that make the user wait a long time? 210 | - **Creativity**: Does the application have a unique look and feel? Does it stand out from other similar applications? 211 | 212 | For the codebase: 213 | 214 | - **Readability**: Is the codebase well-structured and easy to understand? 215 | - **Maintainability**: Is the codebase easy to maintain and extend by other developers? 216 | - **Documentation**: Is the codebase well-documented? Are there comments where necessary? 217 | - **Best Practices**: Does the codebase follow best practices for the technologies used? 218 | 219 | ### Submission 220 | 221 | - Sync all changes to **your private fork** on GitHub. 222 | - Deploy the app on [![Vercel]](https://vercel.com/new/): 223 | - Link your GitHub repository 224 | - Add the necessary environment variables 225 | - Set the deployed app's link (assigned by Vercel) as the website for your GitHub repository (the About section on the repository's homepage). 226 | 227 | This link should be of the form `.vercel.app`. 228 | 229 | - [Invite us](#contact) as [collaborators to your private fork](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-access-to-your-personal-repositories/inviting-collaborators-to-a-personal-repository). 230 | 231 | ## Help 232 | 233 | ### GitHub Issues 234 | 235 | When you encounter issues while working on the project, please attempt to resolve them on your own first with the help of the internet. 236 | If you are still unable to resolve the issue, please create [a new issue](https://github.com/dotlas/golden-plate-map/issues/new/choose) on this repository. 237 | 238 | > **Note** 239 | > Before creating a new issue, please check the [existing issues](https://github.com/dotlas/golden-plate-map/issues) to see if a similar one has already been reported. 240 | > It is possible that someone else has already encountered the same issue and found a solution. 241 | 242 | ### Contact 243 | 244 | Please reach out to us should you have any questions. 245 | 246 | | Name | Contact | 247 | | :-------------- | :------------------------------------------------------------------------------------------------- | 248 | | Kelvin DeCosta | [![GitHub]](https://github.com/kelvindecosta) [![LinkedIn]](https://linkedin.com/in/kelvindecosta) | 249 | | Eshwaran Venkat | [![GitHub]](https://github.com/cricksmaidiene) [![LinkedIn]](https://linkedin.com/in/eshwaranv98) | 250 | 251 | > Feel free to ping us anytime for support. 252 | 253 | [Next.js]: https://img.shields.io/badge/next.js-000000?logo=nextdotjs&logoColor=white "Next.js" 254 | [React]: https://img.shields.io/badge/react-20232A?logo=react&logoColor=61DAFB "React" 255 | [Vercel]: https://img.shields.io/badge/vercel-000000?logo=vercel&logoColor=white "Vercel" 256 | [TypeScript]: https://img.shields.io/badge/typescript-007ACC?logo=typescript&logoColor=white "TypeScript" 257 | [ESLint]: https://img.shields.io/badge/eslint-3A33D1?logo=eslint&logoColor=white "ESLint" 258 | [Prettier]: https://img.shields.io/badge/prettier-1A2C34?logo=prettier&logoColor=F7BA3E "Prettier" 259 | [pnpm]: https://img.shields.io/badge/pnpm-F69220?logo=pnpm&logoColor=white "pnpm" 260 | [Node.js]: https://img.shields.io/badge/node.js-339933?logo=node.js&logoColor=white "Node.js" 261 | [GitHub]: https://img.shields.io/badge/github-181717?logo=github&logoColor=white "GitHub" 262 | [Tailwind]: https://img.shields.io/badge/tailwind-38B2AC?logo=tailwind-css&logoColor=white "Tailwind CSS" 263 | [mapbox]: https://img.shields.io/badge/mapbox-000000?logo=mapbox&logoColor=white "Mapbox" 264 | [LinkedIn]: https://img.shields.io/badge/linkedin-0A66C2?logo=linkedin&logoColor=white "LinkedIn" 265 | --------------------------------------------------------------------------------