├── .env ├── prettier.config.js ├── src ├── app │ ├── favicon.ico │ ├── layout.tsx │ ├── sitemap.ts │ ├── page.tsx │ ├── globals.css │ ├── search │ │ └── page.tsx │ └── [location] │ │ └── [q] │ │ └── page.tsx ├── assets │ └── restaurant-banner.jpg ├── lib │ └── utils.ts ├── components │ ├── ui │ │ ├── skeleton.tsx │ │ ├── label.tsx │ │ ├── input.tsx │ │ ├── badge.tsx │ │ ├── popover.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── dialog.tsx │ │ └── command.tsx │ ├── Header.tsx │ ├── LocationInput.tsx │ └── RestaurantItem.tsx └── data │ └── restaurants.ts ├── postcss.config.mjs ├── next.config.ts ├── eslint.config.mjs ├── components.json ├── README.md ├── .gitignore ├── tsconfig.json ├── package.json └── tailwind.config.ts /.env: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_BASE_URL="http://localhost:3000" -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ["prettier-plugin-tailwindcss"], 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-programmatic-seo/HEAD/src/app/favicon.ico -------------------------------------------------------------------------------- /src/assets/restaurant-banner.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codinginflow/nextjs-programmatic-seo/HEAD/src/assets/restaurant-banner.jpg -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /next.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextConfig } from "next"; 2 | 3 | const nextConfig: NextConfig = { 4 | images: { 5 | remotePatterns: [ 6 | { 7 | protocol: "https", 8 | hostname: "images.unsplash.com", 9 | }, 10 | ], 11 | }, 12 | }; 13 | 14 | export default nextConfig; -------------------------------------------------------------------------------- /src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { dirname } from "path"; 2 | import { fileURLToPath } from "url"; 3 | import { FlatCompat } from "@eslint/eslintrc"; 4 | 5 | const __filename = fileURLToPath(import.meta.url); 6 | const __dirname = dirname(__filename); 7 | 8 | const compat = new FlatCompat({ 9 | baseDirectory: __dirname, 10 | }); 11 | 12 | const eslintConfig = [ 13 | ...compat.extends("next/core-web-vitals", "next/typescript", "prettier"), 14 | ]; 15 | 16 | export default eslintConfig; -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "default", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Programmatic SEO in Next.js 15 2 | 3 | This is the code for my YouTube tutorial on [how to implement programmatic SEO in Next.js 15](https://www.youtube.com/watch?v=290Ytj96vL4). 4 | 5 | Learn how to: 6 | 7 | - Use **dynamic routes** to create **statically cached** page templates that target relevant **long tail keywords** 8 | - Optimize your page's **metadata** 9 | - Set up a **dynamic sitemap** 10 | - Connect a domain and submit your pages to the **Google Search Console** 11 | 12 | ![thumbnail 3](https://github.com/user-attachments/assets/3af2b3df-fcd8-4488-856d-db5776a77989) 13 | -------------------------------------------------------------------------------- /.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.* 7 | .yarn/* 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | 13 | # testing 14 | /coverage 15 | 16 | # next.js 17 | /.next/ 18 | /out/ 19 | 20 | # production 21 | /build 22 | 23 | # misc 24 | .DS_Store 25 | *.pem 26 | 27 | # debug 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | .pnpm-debug.log* 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from "next"; 2 | import { Geist } from "next/font/google"; 3 | import "./globals.css"; 4 | 5 | const geistSans = Geist({ 6 | variable: "--font-geist-sans", 7 | subsets: ["latin"], 8 | }); 9 | 10 | export const metadata: Metadata = { 11 | title: "Restaurant Finder", 12 | description: "Find the best restaurants near you", 13 | }; 14 | 15 | export default function RootLayout({ 16 | children, 17 | }: Readonly<{ 18 | children: React.ReactNode; 19 | }>) { 20 | return ( 21 | 22 | {children} 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Input = React.forwardRef>( 6 | ({ className, type, ...props }, ref) => { 7 | return ( 8 | 17 | ) 18 | } 19 | ) 20 | Input.displayName = "Input" 21 | 22 | export { Input } 23 | -------------------------------------------------------------------------------- /src/app/sitemap.ts: -------------------------------------------------------------------------------- 1 | import { getAllTags, locations } from "@/data/restaurants"; 2 | import { MetadataRoute } from "next"; 3 | 4 | const baseUrl = process.env.NEXT_PUBLIC_BASE_URL; 5 | 6 | export default async function sitemap(): Promise { 7 | const allTags = await getAllTags(); 8 | 9 | const searchLandingPages = allTags 10 | .map((tag) => 11 | locations.map((location) => ({ 12 | url: `${baseUrl}/${location}/${tag}`, 13 | lastModified: new Date(), 14 | changeFrequency: "weekly", 15 | priority: 1, 16 | })), 17 | ) 18 | .flat() as MetadataRoute.Sitemap; 19 | 20 | return [ 21 | // Insert your other pages: 22 | { 23 | url: `${baseUrl}/about`, 24 | lastModified: "2024-12-31", 25 | changeFrequency: "yearly", 26 | priority: 0.8, 27 | }, 28 | // Our pSEO pages: 29 | ...searchLandingPages, 30 | ]; 31 | } 32 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | import banner from "@/assets/restaurant-banner.jpg"; 2 | import Header from "@/components/Header"; 3 | import Image from "next/image"; 4 | 5 | export default async function Home() { 6 | return ( 7 |
8 |
9 |
10 |
11 | Restaurant Finder 18 |
19 |

20 | Find the best restaurants near you 21 |

22 |

23 | Search for your favorite cuisine, restaurant, or dish 24 |

25 |
26 |
27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nextjs-programmatic-seo", 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 | }, 11 | "dependencies": { 12 | "@radix-ui/react-dialog": "^1.1.5", 13 | "@radix-ui/react-label": "^2.1.1", 14 | "@radix-ui/react-popover": "^1.1.5", 15 | "@radix-ui/react-slot": "^1.1.1", 16 | "class-variance-authority": "^0.7.1", 17 | "clsx": "^2.1.1", 18 | "cmdk": "^1.0.0", 19 | "lucide-react": "^0.474.0", 20 | "next": "15.1.6", 21 | "react": "^19.0.0", 22 | "react-dom": "^19.0.0", 23 | "tailwind-merge": "^3.0.1", 24 | "tailwindcss-animate": "^1.0.7" 25 | }, 26 | "devDependencies": { 27 | "@eslint/eslintrc": "^3", 28 | "@types/node": "^20", 29 | "@types/react": "^19", 30 | "@types/react-dom": "^19", 31 | "eslint": "^9", 32 | "eslint-config-next": "15.1.6", 33 | "eslint-config-prettier": "^10.0.1", 34 | "postcss": "^8", 35 | "prettier": "^3.4.2", 36 | "prettier-plugin-tailwindcss": "^0.6.11", 37 | "tailwindcss": "^3.4.1", 38 | "typescript": "^5" 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as PopoverPrimitive from "@radix-ui/react-popover" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Popover = PopoverPrimitive.Root 9 | 10 | const PopoverTrigger = PopoverPrimitive.Trigger 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent } 32 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | export default { 4 | darkMode: ["class"], 5 | content: [ 6 | "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./src/components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./src/app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | } satisfies Config; 63 | -------------------------------------------------------------------------------- /src/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | body { 6 | font-family: Geist, Arial, Helvetica, sans-serif; 7 | } 8 | 9 | @layer base { 10 | :root { 11 | --background: 0 0% 100%; 12 | --foreground: 240 10% 3.9%; 13 | --card: 0 0% 100%; 14 | --card-foreground: 240 10% 3.9%; 15 | --popover: 0 0% 100%; 16 | --popover-foreground: 240 10% 3.9%; 17 | --primary: 126, 38%, 48%; 18 | --primary-foreground: 355.7 100% 97.3%; 19 | --secondary: 240 4.8% 95.9%; 20 | --secondary-foreground: 240 5.9% 10%; 21 | --muted: 240 4.8% 95.9%; 22 | --muted-foreground: 240 3.8% 46.1%; 23 | --accent: 240 4.8% 95.9%; 24 | --accent-foreground: 240 5.9% 10%; 25 | --destructive: 0 84.2% 60.2%; 26 | --destructive-foreground: 0 0% 98%; 27 | --border: 240 5.9% 90%; 28 | --input: 240 5.9% 90%; 29 | --ring: 142.1 76.2% 36.3%; 30 | --radius: 1rem; 31 | --chart-1: 12 76% 61%; 32 | --chart-2: 173 58% 39%; 33 | --chart-3: 197 37% 24%; 34 | --chart-4: 43 74% 66%; 35 | --chart-5: 27 87% 67%; 36 | } 37 | 38 | .dark { 39 | --background: 20 14.3% 4.1%; 40 | --foreground: 0 0% 95%; 41 | --card: 24 9.8% 10%; 42 | --card-foreground: 0 0% 95%; 43 | --popover: 0 0% 9%; 44 | --popover-foreground: 0 0% 95%; 45 | --primary: 126, 38%, 48%; 46 | --primary-foreground: 144.9 80.4% 10%; 47 | --secondary: 240 3.7% 15.9%; 48 | --secondary-foreground: 0 0% 98%; 49 | --muted: 0 0% 15%; 50 | --muted-foreground: 240 5% 64.9%; 51 | --accent: 12 6.5% 15.1%; 52 | --accent-foreground: 0 0% 98%; 53 | --destructive: 0 62.8% 30.6%; 54 | --destructive-foreground: 0 85.7% 97.3%; 55 | --border: 240 3.7% 15.9%; 56 | --input: 240 3.7% 15.9%; 57 | --ring: 142.4 71.8% 29.2%; 58 | --chart-1: 220 70% 50%; 59 | --chart-2: 160 60% 45%; 60 | --chart-3: 30 80% 55%; 61 | --chart-4: 280 65% 60%; 62 | --chart-5: 340 75% 55%; 63 | } 64 | } 65 | 66 | @layer base { 67 | * { 68 | @apply border-border; 69 | } 70 | body { 71 | @apply bg-background text-foreground; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/components/Header.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import LocationInput from "@/components/LocationInput"; 4 | import { Button } from "@/components/ui/button"; 5 | import { Input } from "@/components/ui/input"; 6 | import { Search } from "lucide-react"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/navigation"; 9 | 10 | interface HeaderProps { 11 | q?: string; 12 | location?: string; 13 | } 14 | 15 | export default function Header({ q, location }: HeaderProps) { 16 | const router = useRouter(); 17 | 18 | function handleSubmit(event: React.FormEvent) { 19 | event.preventDefault(); 20 | const formData = new FormData(event.currentTarget); 21 | const q = formData.get("q") as string; 22 | const location = formData.get("location") as string; 23 | const newSearchParams = new URLSearchParams(); 24 | newSearchParams.set("q", q); 25 | if (location) newSearchParams.set("location", location); 26 | router.push(`/search?${newSearchParams.toString()}`); 27 | } 28 | 29 | return ( 30 |
31 |
32 |
33 | 34 | 35 | Restaurant Finder 36 | 37 | 38 |
44 | 51 | 52 | 56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | -------------------------------------------------------------------------------- /src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-destructive-foreground hover:bg-destructive/90", 15 | outline: 16 | "border border-input bg-background hover:bg-accent hover:text-accent-foreground", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: "hover:bg-accent hover:text-accent-foreground", 20 | link: "text-primary underline-offset-4 hover:underline", 21 | }, 22 | size: { 23 | default: "h-10 px-4 py-2", 24 | sm: "h-9 rounded-md px-3", 25 | lg: "h-11 rounded-md px-8", 26 | icon: "h-10 w-10", 27 | }, 28 | }, 29 | defaultVariants: { 30 | variant: "default", 31 | size: "default", 32 | }, 33 | } 34 | ) 35 | 36 | export interface ButtonProps 37 | extends React.ButtonHTMLAttributes, 38 | VariantProps { 39 | asChild?: boolean 40 | } 41 | 42 | const Button = React.forwardRef( 43 | ({ className, variant, size, asChild = false, ...props }, ref) => { 44 | const Comp = asChild ? Slot : "button" 45 | return ( 46 | 51 | ) 52 | } 53 | ) 54 | Button.displayName = "Button" 55 | 56 | export { Button, buttonVariants } 57 | -------------------------------------------------------------------------------- /src/app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/Header"; 2 | import RestaurantItem from "@/components/RestaurantItem"; 3 | import { Skeleton } from "@/components/ui/skeleton"; 4 | import { locations, searchRestaurants } from "@/data/restaurants"; 5 | import { redirect } from "next/navigation"; 6 | import { Suspense } from "react"; 7 | 8 | interface PageProps { 9 | searchParams: Promise<{ q?: string; location?: string }>; 10 | } 11 | 12 | export default async function Page({ searchParams }: PageProps) { 13 | const { q, location } = await searchParams; 14 | 15 | if (!q) redirect("/"); 16 | 17 | // In a real app, a missing location param could automatically search close to the user's location (That's how Yelp does it) 18 | const userLocation = location || locations[0]; 19 | 20 | return ( 21 |
22 |
23 | } key={`${q}-${location}`}> 24 | 25 | 26 |
27 | ); 28 | } 29 | 30 | interface ResultsProps { 31 | q: string; 32 | location: string; 33 | } 34 | 35 | async function Results({ q, location }: ResultsProps) { 36 | const results = await searchRestaurants(q, location); 37 | 38 | return ( 39 |
40 |

41 | Showing {results.length} results for {`"${q}"`} near {location} 42 |

43 |
44 | {results.map((restaurant) => ( 45 | 46 | ))} 47 |
48 |
49 | ); 50 | } 51 | 52 | function ResultsLoadingSkeleton() { 53 | return ( 54 |
55 | 56 |
57 | {Array.from({ length: 6 }).map((_, i) => ( 58 | 59 | ))} 60 |
61 |
62 | ); 63 | } 64 | -------------------------------------------------------------------------------- /src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLDivElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |
44 | )) 45 | CardTitle.displayName = "CardTitle" 46 | 47 | const CardDescription = React.forwardRef< 48 | HTMLDivElement, 49 | React.HTMLAttributes 50 | >(({ className, ...props }, ref) => ( 51 |
56 | )) 57 | CardDescription.displayName = "CardDescription" 58 | 59 | const CardContent = React.forwardRef< 60 | HTMLDivElement, 61 | React.HTMLAttributes 62 | >(({ className, ...props }, ref) => ( 63 |
64 | )) 65 | CardContent.displayName = "CardContent" 66 | 67 | const CardFooter = React.forwardRef< 68 | HTMLDivElement, 69 | React.HTMLAttributes 70 | >(({ className, ...props }, ref) => ( 71 |
76 | )) 77 | CardFooter.displayName = "CardFooter" 78 | 79 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 80 | -------------------------------------------------------------------------------- /src/app/[location]/[q]/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/Header"; 2 | import RestaurantItem from "@/components/RestaurantItem"; 3 | import { getAllTags, locations, searchRestaurants } from "@/data/restaurants"; 4 | import { Metadata } from "next"; 5 | import { cache } from "react"; 6 | 7 | interface PageProps { 8 | params: Promise<{ location: string; q: string }>; 9 | } 10 | 11 | export const revalidate = 86400; // Refresh cached pages once every 24 hours 12 | 13 | export async function generateStaticParams() { 14 | const allTags = await getAllTags({ 15 | // If you have very many pages, you can only render a subset at compile-time. The rest will be rendered & cached at first access. 16 | // limit: 10 17 | }); 18 | 19 | return allTags 20 | .map((tag) => 21 | locations.map((location) => ({ 22 | location, 23 | q: tag, 24 | })), 25 | ) 26 | .flat(); 27 | } 28 | 29 | const getRestaurants = cache(searchRestaurants); 30 | 31 | export async function generateMetadata({ 32 | params, 33 | }: PageProps): Promise { 34 | const { q, location } = await params; 35 | 36 | const qDecoded = decodeURIComponent(q); 37 | const locationDecoded = decodeURIComponent(location); 38 | 39 | const results = await getRestaurants(qDecoded, locationDecoded); 40 | 41 | return { 42 | title: `Top ${results.length} ${qDecoded} near ${locationDecoded} - Updated ${new Date().getFullYear()}`, 43 | description: `Find the best ${qDecoded} near ${locationDecoded}`, 44 | }; 45 | } 46 | 47 | export default async function Page({ params }: PageProps) { 48 | const { q, location } = await params; 49 | 50 | const qDecoded = decodeURIComponent(q); 51 | const locationDecoded = decodeURIComponent(location); 52 | 53 | const results = await getRestaurants(qDecoded, locationDecoded); 54 | 55 | return ( 56 |
57 |
58 |
59 |

60 | Top {results.length} {qDecoded} near {locationDecoded} 61 |

62 |
63 | {results.map((restaurant) => ( 64 | 65 | ))} 66 |
67 |
68 |
69 | ); 70 | } 71 | -------------------------------------------------------------------------------- /src/components/LocationInput.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Command, 4 | CommandEmpty, 5 | CommandGroup, 6 | CommandInput, 7 | CommandItem, 8 | CommandList, 9 | } from "@/components/ui/command"; 10 | import { 11 | Popover, 12 | PopoverContent, 13 | PopoverTrigger, 14 | } from "@/components/ui/popover"; 15 | import { locations } from "@/data/restaurants"; 16 | import { cn } from "@/lib/utils"; 17 | import { Check, ChevronsUpDown } from "lucide-react"; 18 | import { useState } from "react"; 19 | 20 | interface LocationInputProps { 21 | name?: string; 22 | defaultValue?: string; 23 | } 24 | 25 | export default function LocationInput({ 26 | name, 27 | defaultValue, 28 | }: LocationInputProps) { 29 | const [open, setOpen] = useState(false); 30 | const [input, setInput] = useState(defaultValue); 31 | 32 | return ( 33 | 34 | 35 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | No location found. 54 | 55 | {locations.map((location) => ( 56 | { 60 | setInput(currentValue === input ? "" : currentValue); 61 | setOpen(false); 62 | }} 63 | > 64 | 70 | {location} 71 | 72 | ))} 73 | 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/RestaurantItem.tsx: -------------------------------------------------------------------------------- 1 | import { Badge } from "@/components/ui/badge"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { Restaurant } from "@/data/restaurants"; 10 | import { Clock, MapPin, Phone, Star, StarHalf } from "lucide-react"; 11 | import Image from "next/image"; 12 | 13 | interface RestaurantItemProps { 14 | restaurant: Restaurant; 15 | } 16 | 17 | export default function RestaurantItem({ restaurant }: RestaurantItemProps) { 18 | return ( 19 | 20 | {restaurant.name} 27 | 28 |
29 |
30 | {restaurant.name} 31 | 32 | {restaurant.cuisine} • {restaurant.price} 33 | 34 |
35 |
36 |
37 | 38 |
39 | 40 | ({restaurant.reviews}) 41 | 42 |
43 |
44 |
45 | 46 |
47 |
48 | 49 | {restaurant.address} 50 |
51 |
52 | 53 | {restaurant.phone} 54 |
55 |
56 | 57 | {restaurant.hours} 58 |
59 |
60 | {restaurant.tags.map((tag) => ( 61 | 62 | {tag} 63 | 64 | ))} 65 |
66 |
67 |
68 |
69 | ); 70 | } 71 | 72 | interface StarRatingProps { 73 | rating: number; 74 | } 75 | 76 | function StarRating({ rating }: StarRatingProps) { 77 | const fullStars = Math.floor(rating); 78 | const hasHalfStar = rating % 1 !== 0; 79 | 80 | return ( 81 |
82 | {Array.from({ length: fullStars }).map((_, i) => ( 83 | 87 | ))} 88 | {hasHalfStar && ( 89 | 93 | )} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as DialogPrimitive from "@radix-ui/react-dialog" 5 | import { X } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Dialog = DialogPrimitive.Root 10 | 11 | const DialogTrigger = DialogPrimitive.Trigger 12 | 13 | const DialogPortal = DialogPrimitive.Portal 14 | 15 | const DialogClose = DialogPrimitive.Close 16 | 17 | const DialogOverlay = React.forwardRef< 18 | React.ElementRef, 19 | React.ComponentPropsWithoutRef 20 | >(({ className, ...props }, ref) => ( 21 | 29 | )) 30 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 31 | 32 | const DialogContent = React.forwardRef< 33 | React.ElementRef, 34 | React.ComponentPropsWithoutRef 35 | >(({ className, children, ...props }, ref) => ( 36 | 37 | 38 | 46 | {children} 47 | 48 | 49 | Close 50 | 51 | 52 | 53 | )) 54 | DialogContent.displayName = DialogPrimitive.Content.displayName 55 | 56 | const DialogHeader = ({ 57 | className, 58 | ...props 59 | }: React.HTMLAttributes) => ( 60 |
67 | ) 68 | DialogHeader.displayName = "DialogHeader" 69 | 70 | const DialogFooter = ({ 71 | className, 72 | ...props 73 | }: React.HTMLAttributes) => ( 74 |
81 | ) 82 | DialogFooter.displayName = "DialogFooter" 83 | 84 | const DialogTitle = React.forwardRef< 85 | React.ElementRef, 86 | React.ComponentPropsWithoutRef 87 | >(({ className, ...props }, ref) => ( 88 | 96 | )) 97 | DialogTitle.displayName = DialogPrimitive.Title.displayName 98 | 99 | const DialogDescription = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | DialogDescription.displayName = DialogPrimitive.Description.displayName 110 | 111 | export { 112 | Dialog, 113 | DialogPortal, 114 | DialogOverlay, 115 | DialogClose, 116 | DialogTrigger, 117 | DialogContent, 118 | DialogHeader, 119 | DialogFooter, 120 | DialogTitle, 121 | DialogDescription, 122 | } 123 | -------------------------------------------------------------------------------- /src/components/ui/command.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { type DialogProps } from "@radix-ui/react-dialog" 5 | import { Command as CommandPrimitive } from "cmdk" 6 | import { Search } from "lucide-react" 7 | 8 | import { cn } from "@/lib/utils" 9 | import { Dialog, DialogContent } from "@/components/ui/dialog" 10 | 11 | const Command = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef 14 | >(({ className, ...props }, ref) => ( 15 | 23 | )) 24 | Command.displayName = CommandPrimitive.displayName 25 | 26 | const CommandDialog = ({ children, ...props }: DialogProps) => { 27 | return ( 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | const CommandInput = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 |
43 | 44 | 52 |
53 | )) 54 | 55 | CommandInput.displayName = CommandPrimitive.Input.displayName 56 | 57 | const CommandList = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 66 | )) 67 | 68 | CommandList.displayName = CommandPrimitive.List.displayName 69 | 70 | const CommandEmpty = React.forwardRef< 71 | React.ElementRef, 72 | React.ComponentPropsWithoutRef 73 | >((props, ref) => ( 74 | 79 | )) 80 | 81 | CommandEmpty.displayName = CommandPrimitive.Empty.displayName 82 | 83 | const CommandGroup = React.forwardRef< 84 | React.ElementRef, 85 | React.ComponentPropsWithoutRef 86 | >(({ className, ...props }, ref) => ( 87 | 95 | )) 96 | 97 | CommandGroup.displayName = CommandPrimitive.Group.displayName 98 | 99 | const CommandSeparator = React.forwardRef< 100 | React.ElementRef, 101 | React.ComponentPropsWithoutRef 102 | >(({ className, ...props }, ref) => ( 103 | 108 | )) 109 | CommandSeparator.displayName = CommandPrimitive.Separator.displayName 110 | 111 | const CommandItem = React.forwardRef< 112 | React.ElementRef, 113 | React.ComponentPropsWithoutRef 114 | >(({ className, ...props }, ref) => ( 115 | 123 | )) 124 | 125 | CommandItem.displayName = CommandPrimitive.Item.displayName 126 | 127 | const CommandShortcut = ({ 128 | className, 129 | ...props 130 | }: React.HTMLAttributes) => { 131 | return ( 132 | 139 | ) 140 | } 141 | CommandShortcut.displayName = "CommandShortcut" 142 | 143 | export { 144 | Command, 145 | CommandDialog, 146 | CommandInput, 147 | CommandList, 148 | CommandEmpty, 149 | CommandGroup, 150 | CommandItem, 151 | CommandShortcut, 152 | CommandSeparator, 153 | } 154 | -------------------------------------------------------------------------------- /src/data/restaurants.ts: -------------------------------------------------------------------------------- 1 | export interface Restaurant { 2 | id: number; 3 | name: string; 4 | image: string; 5 | rating: number; 6 | price: string; 7 | cuisine: string; 8 | address: string; 9 | phone: string; 10 | hours: string; 11 | reviews: number; 12 | tags: string[]; 13 | } 14 | 15 | const restaurants: Restaurant[] = [ 16 | { 17 | id: 1, 18 | name: "Sushi Master SF", 19 | image: 20 | "https://images.unsplash.com/photo-1579871494447-9811cf80d66c?w=800&auto=format&fit=crop&q=60", 21 | rating: 4.8, 22 | price: "$$$", 23 | cuisine: "Japanese", 24 | address: "456 Fillmore St, San Francisco, CA", 25 | phone: "(415) 987-6543", 26 | hours: "11:00 AM - 11:00 PM", 27 | reviews: 456, 28 | tags: ["Lunch", "Dinner", "Sushi Bars"], 29 | }, 30 | { 31 | id: 2, 32 | name: "Golden Dragon SF", 33 | image: 34 | "https://images.unsplash.com/photo-1552566626-52f8b828add9?w=800&auto=format&fit=crop&q=60", 35 | rating: 4.7, 36 | price: "$$", 37 | cuisine: "Chinese", 38 | address: "768 Grant Ave, San Francisco, CA", 39 | phone: "(415) 666-7777", 40 | hours: "11:00 AM - 10:00 PM", 41 | reviews: 321, 42 | tags: ["Lunch", "Dinner", "Chinese"], 43 | }, 44 | { 45 | id: 3, 46 | name: "Mission Tacos", 47 | image: 48 | "https://images.unsplash.com/photo-1565299585323-38d6b0865b47?w=800&auto=format&fit=crop&q=60", 49 | rating: 4.6, 50 | price: "$", 51 | cuisine: "Mexican", 52 | address: "2234 Mission St, San Francisco, CA", 53 | phone: "(415) 777-8888", 54 | hours: "10:00 AM - 10:00 PM", 55 | reviews: 567, 56 | tags: ["Lunch", "Dinner", "Mexican"], 57 | }, 58 | { 59 | id: 4, 60 | name: "Marina Steakhouse", 61 | image: 62 | "https://images.unsplash.com/photo-1544025162-d76694265947?w=800&auto=format&fit=crop&q=60", 63 | rating: 4.9, 64 | price: "$$$$", 65 | cuisine: "Steakhouse", 66 | address: "3300 Fillmore St, San Francisco, CA", 67 | phone: "(415) 123-4567", 68 | hours: "5:00 PM - 11:00 PM", 69 | reviews: 234, 70 | tags: ["Dinner", "Steak", "Fine Dining"], 71 | }, 72 | { 73 | id: 5, 74 | name: "Fog City Pizza", 75 | image: 76 | "https://images.unsplash.com/photo-1579751626657-72bc17010498?w=800&auto=format&fit=crop&q=60", 77 | rating: 4.5, 78 | price: "$$", 79 | cuisine: "Pizza", 80 | address: "1512 Columbus Ave, San Francisco, CA", 81 | phone: "(415) 555-1234", 82 | hours: "11:00 AM - 10:00 PM", 83 | reviews: 345, 84 | tags: ["Lunch", "Dinner", "Pizza"], 85 | }, 86 | { 87 | id: 6, 88 | name: "Noe Valley Brunch", 89 | image: 90 | "https://images.unsplash.com/photo-1504754524776-8f4f37790ca0?w=800&auto=format&fit=crop&q=60", 91 | rating: 4.4, 92 | price: "$$", 93 | cuisine: "Breakfast", 94 | address: "3901 24th St, San Francisco, CA", 95 | phone: "(415) 777-9876", 96 | hours: "8:00 AM - 3:00 PM", 97 | reviews: 289, 98 | tags: ["Breakfast", "Brunch", "Coffee"], 99 | }, 100 | { 101 | id: 19, 102 | name: "Sakura SF", 103 | image: 104 | "https://images.unsplash.com/photo-1563612116625-3012372fccce?w=800&auto=format&fit=crop&q=60", 105 | rating: 4.6, 106 | price: "$$", 107 | cuisine: "Japanese", 108 | address: "2223 Market St, San Francisco, CA", 109 | phone: "(415) 444-5555", 110 | hours: "11:30 AM - 10:00 PM", 111 | reviews: 234, 112 | tags: ["Lunch", "Dinner", "Sushi Bars"], 113 | }, 114 | { 115 | id: 20, 116 | name: "Tsunami Sushi SF", 117 | image: 118 | "https://images.unsplash.com/photo-1534482421-64566f976cfa?w=800&auto=format&fit=crop&q=60", 119 | rating: 4.7, 120 | price: "$$$$", 121 | cuisine: "Japanese", 122 | address: "1306 Fulton St, San Francisco, CA", 123 | phone: "(415) 333-4444", 124 | hours: "12:00 PM - 11:00 PM", 125 | reviews: 567, 126 | tags: ["Lunch", "Dinner", "Sushi Bars", "Fine Dining"], 127 | }, 128 | { 129 | id: 7, 130 | name: "Sushi Master Chicago", 131 | image: 132 | "https://images.unsplash.com/photo-1553621042-f6e147245754?w=800&auto=format&fit=crop&q=60", 133 | rating: 4.7, 134 | price: "$$$", 135 | cuisine: "Japanese", 136 | address: "555 N State St, Chicago, IL", 137 | phone: "(312) 888-9999", 138 | hours: "11:00 AM - 11:00 PM", 139 | reviews: 678, 140 | tags: ["Lunch", "Dinner", "Sushi Bars"], 141 | }, 142 | { 143 | id: 8, 144 | name: "Golden Dragon Chicago", 145 | image: 146 | "https://images.unsplash.com/photo-1526318896980-cf78c088247c?w=800&auto=format&fit=crop&q=60", 147 | rating: 4.6, 148 | price: "$$", 149 | cuisine: "Chinese", 150 | address: "999 Devon Ave, Chicago, IL", 151 | phone: "(312) 555-6666", 152 | hours: "11:00 AM - 10:00 PM", 153 | reviews: 432, 154 | tags: ["Lunch", "Dinner", "Chinese"], 155 | }, 156 | { 157 | id: 9, 158 | name: "Deep Dish Heaven", 159 | image: 160 | "https://images.unsplash.com/photo-1513104890138-7c749659a591?w=800&auto=format&fit=crop&q=60", 161 | rating: 4.7, 162 | price: "$$", 163 | cuisine: "Pizza", 164 | address: "742 N Wells St, Chicago, IL", 165 | phone: "(312) 222-3333", 166 | hours: "11:00 AM - 11:00 PM", 167 | reviews: 789, 168 | tags: ["Lunch", "Dinner", "Pizza"], 169 | }, 170 | { 171 | id: 10, 172 | name: "Wrigleyville Steakhouse", 173 | image: 174 | "https://images.unsplash.com/photo-1546833999-b9f581a1996d?w=800&auto=format&fit=crop&q=60", 175 | rating: 4.8, 176 | price: "$$$$", 177 | cuisine: "Steakhouse", 178 | address: "3501 N Clark St, Chicago, IL", 179 | phone: "(312) 444-5555", 180 | hours: "4:00 PM - 11:00 PM", 181 | reviews: 567, 182 | tags: ["Dinner", "Steak", "Fine Dining"], 183 | }, 184 | { 185 | id: 11, 186 | name: "Pilsen Mexican Grill", 187 | image: 188 | "https://images.unsplash.com/photo-1551504734-5ee1c4a1479b?w=800&auto=format&fit=crop&q=60", 189 | rating: 4.5, 190 | price: "$", 191 | cuisine: "Mexican", 192 | address: "1235 W 18th St, Chicago, IL", 193 | phone: "(312) 777-8888", 194 | hours: "10:00 AM - 10:00 PM", 195 | reviews: 345, 196 | tags: ["Lunch", "Dinner", "Mexican"], 197 | }, 198 | { 199 | id: 12, 200 | name: "Logan Square Brunch", 201 | image: 202 | "https://images.unsplash.com/photo-1525351484163-7529414344d8?w=800&auto=format&fit=crop&q=60", 203 | rating: 4.6, 204 | price: "$$", 205 | cuisine: "Breakfast", 206 | address: "2537 N Kedzie Ave, Chicago, IL", 207 | phone: "(312) 999-1111", 208 | hours: "7:00 AM - 3:00 PM", 209 | reviews: 234, 210 | tags: ["Breakfast", "Brunch", "Coffee"], 211 | }, 212 | { 213 | id: 21, 214 | name: "Umami Chicago", 215 | image: 216 | "https://images.unsplash.com/photo-1579584425555-c3ce17fd4351?w=800&auto=format&fit=crop&q=60", 217 | rating: 4.9, 218 | price: "$$$$", 219 | cuisine: "Japanese", 220 | address: "723 W Randolph St, Chicago, IL", 221 | phone: "(312) 666-7777", 222 | hours: "4:00 PM - 11:00 PM", 223 | reviews: 890, 224 | tags: ["Dinner", "Sushi Bars", "Fine Dining"], 225 | }, 226 | { 227 | id: 22, 228 | name: "Fuji Sushi", 229 | image: 230 | "https://images.unsplash.com/photo-1611143669185-af224c5e3252?w=800&auto=format&fit=crop&q=60", 231 | rating: 4.5, 232 | price: "$$", 233 | cuisine: "Japanese", 234 | address: "2345 N Lincoln Ave, Chicago, IL", 235 | phone: "(312) 555-4444", 236 | hours: "11:30 AM - 10:00 PM", 237 | reviews: 456, 238 | tags: ["Lunch", "Dinner", "Sushi Bars"], 239 | }, 240 | { 241 | id: 13, 242 | name: "Sushi Master Miami", 243 | image: 244 | "https://images.unsplash.com/photo-1611143669185-af224c5e3252?w=800&auto=format&fit=crop&q=60", 245 | rating: 4.8, 246 | price: "$$$", 247 | cuisine: "Japanese", 248 | address: "222 Brickell Ave, Miami, FL", 249 | phone: "(305) 888-7777", 250 | hours: "11:00 AM - 11:00 PM", 251 | reviews: 345, 252 | tags: ["Lunch", "Dinner", "Sushi Bars"], 253 | }, 254 | { 255 | id: 14, 256 | name: "Golden Dragon Miami", 257 | image: 258 | "https://images.unsplash.com/photo-1569058242253-92a9c755a0ec?w=800&auto=format&fit=crop&q=60", 259 | rating: 4.6, 260 | price: "$$", 261 | cuisine: "Chinese", 262 | address: "567 NW 27th St, Miami, FL", 263 | phone: "(305) 999-8888", 264 | hours: "11:00 AM - 10:00 PM", 265 | reviews: 678, 266 | tags: ["Lunch", "Dinner", "Chinese"], 267 | }, 268 | { 269 | id: 15, 270 | name: "South Beach Steakhouse", 271 | image: 272 | "https://images.unsplash.com/photo-1594041680534-e8c8cdebd659?w=800&auto=format&fit=crop&q=60", 273 | rating: 4.9, 274 | price: "$$$$", 275 | cuisine: "Steakhouse", 276 | address: "801 Ocean Drive, Miami Beach, FL", 277 | phone: "(305) 111-2222", 278 | hours: "5:00 PM - 11:00 PM", 279 | reviews: 432, 280 | tags: ["Dinner", "Steak", "Fine Dining"], 281 | }, 282 | { 283 | id: 16, 284 | name: "Little Havana Cafe", 285 | image: 286 | "https://images.unsplash.com/photo-1485182708500-e8f1f318ba72?w=800&auto=format&fit=crop&q=60", 287 | rating: 4.8, 288 | price: "$$", 289 | cuisine: "Cuban", 290 | address: "1567 SW 8th St, Miami, FL", 291 | phone: "(305) 777-6666", 292 | hours: "8:00 AM - 10:00 PM", 293 | reviews: 678, 294 | tags: ["Breakfast", "Lunch", "Dinner", "Cuban"], 295 | }, 296 | { 297 | id: 17, 298 | name: "Coral Way Pizza", 299 | image: 300 | "https://images.unsplash.com/photo-1574071318508-1cdbab80d002?w=800&auto=format&fit=crop&q=60", 301 | rating: 4.5, 302 | price: "$$", 303 | cuisine: "Pizza", 304 | address: "2550 SW 22nd St, Miami, FL", 305 | phone: "(305) 333-4444", 306 | hours: "11:00 AM - 11:00 PM", 307 | reviews: 567, 308 | tags: ["Lunch", "Dinner", "Pizza"], 309 | }, 310 | { 311 | id: 18, 312 | name: "Coconut Grove Brunch", 313 | image: 314 | "https://images.unsplash.com/photo-1533089860892-a7c6f0a88666?w=800&auto=format&fit=crop&q=60", 315 | rating: 4.7, 316 | price: "$$", 317 | cuisine: "Breakfast", 318 | address: "3416 Main Hwy, Miami, FL", 319 | phone: "(305) 222-5555", 320 | hours: "8:00 AM - 3:00 PM", 321 | reviews: 345, 322 | tags: ["Breakfast", "Brunch", "Coffee"], 323 | }, 324 | { 325 | id: 23, 326 | name: "Sushi Zen Miami", 327 | image: 328 | "https://images.unsplash.com/photo-1563612116625-3012372fccce?w=800&auto=format&fit=crop&q=60", 329 | rating: 4.8, 330 | price: "$$$", 331 | cuisine: "Japanese", 332 | address: "1035 N Miami Ave, Miami, FL", 333 | phone: "(305) 444-3333", 334 | hours: "12:00 PM - 11:00 PM", 335 | reviews: 567, 336 | tags: ["Lunch", "Dinner", "Sushi Bars"], 337 | }, 338 | { 339 | id: 24, 340 | name: "Ocean Drive Sushi", 341 | image: 342 | "https://images.unsplash.com/photo-1579584425555-c3ce17fd4351?w=800&auto=format&fit=crop&q=60", 343 | rating: 4.7, 344 | price: "$$$$", 345 | cuisine: "Japanese", 346 | address: "1424 Ocean Drive, Miami Beach, FL", 347 | phone: "(305) 666-5555", 348 | hours: "4:00 PM - 12:00 AM", 349 | reviews: 789, 350 | tags: ["Dinner", "Sushi Bars", "Fine Dining"], 351 | }, 352 | ]; 353 | 354 | export async function searchRestaurants(q: string, location: string) { 355 | // Pretend we're making a request to our DB/API 356 | await new Promise((resolve) => setTimeout(resolve, 1500)); 357 | 358 | const searchWords = q?.split(" ").filter(Boolean) || []; 359 | 360 | return restaurants 361 | .filter((restaurant) => 362 | searchWords.every( 363 | (word) => 364 | restaurant.name.toLowerCase().includes(word.toLowerCase()) || 365 | restaurant.cuisine.toLowerCase().includes(word.toLowerCase()) || 366 | restaurant.tags.some((tag) => 367 | tag.toLowerCase().includes(word.toLowerCase()) 368 | ) 369 | ) 370 | ) 371 | .filter((restaurant) => 372 | restaurant.address.toLowerCase().includes(location.toLowerCase()) 373 | ) 374 | .sort((a, b) => b.rating - a.rating); 375 | } 376 | 377 | export const locations = ["San Francisco, CA", "Chicago, IL", "Miami, FL"]; 378 | 379 | export async function getAllTags({ limit }: { limit?: number } = {}) { 380 | // Pretend we're fetching these from the DB 381 | await new Promise((resolve) => setTimeout(resolve, 1500)); 382 | 383 | return restaurants 384 | .slice(0, limit) 385 | .reduce(function (acc, restaurant) { 386 | return acc.concat(restaurant.tags); 387 | }, []); 388 | } 389 | --------------------------------------------------------------------------------