├── .env ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── app ├── components │ ├── AuthModal.tsx │ ├── AuthModalInputs.tsx │ ├── Header.tsx │ ├── NavBar.tsx │ ├── Price.tsx │ ├── RestaurantCard.tsx │ ├── SearchBar.tsx │ └── Stars.tsx ├── context │ └── AuthContext.tsx ├── globals.css ├── head.tsx ├── layout.tsx ├── loading.tsx ├── page.module.css ├── page.tsx ├── reserve │ └── [slug] │ │ ├── components │ │ ├── Form.tsx │ │ └── Header.tsx │ │ ├── head.tsx │ │ └── page.tsx ├── restaurant │ ├── [slug] │ │ ├── components │ │ │ ├── Description.tsx │ │ │ ├── Header.tsx │ │ │ ├── Images.tsx │ │ │ ├── Menu.tsx │ │ │ ├── MenuCard.tsx │ │ │ ├── Rating.tsx │ │ │ ├── ReservationCard.tsx │ │ │ ├── RestaurantNavBar.tsx │ │ │ ├── ReviewCard.tsx │ │ │ ├── Reviews.tsx │ │ │ └── Title.tsx │ │ ├── head.tsx │ │ ├── layout.tsx │ │ ├── menu │ │ │ ├── head.tsx │ │ │ └── page.tsx │ │ └── page.tsx │ ├── error.tsx │ ├── loading.tsx │ └── not-found.tsx └── search │ ├── components │ ├── Header.tsx │ ├── RestaurantCard.tsx │ └── SearchSideBar.tsx │ ├── head.tsx │ └── page.tsx ├── data ├── index.ts ├── partySize.ts └── times.ts ├── hooks ├── useAuth.ts ├── useAvailabilities.ts └── useReservation.ts ├── html ├── homepage.html ├── reservationPage.html ├── restaurantDetailsPage.html ├── restaurantMenuPage.html └── searchPage.html ├── middleware.ts ├── next.config.js ├── package-lock.json ├── package.json ├── pages └── api │ ├── auth │ ├── me.ts │ ├── signin.ts │ └── signup.ts │ ├── hello.ts │ ├── restaurant │ └── [slug] │ │ ├── availability.ts │ │ └── reserve.ts │ └── seed.ts ├── postcss.config.js ├── prisma └── schema.prisma ├── public ├── favicon.ico ├── icons │ ├── empty-star.png │ ├── error.png │ ├── full-star.png │ └── half-star.png ├── icons8-restaurant-on-site-16.png ├── next.svg ├── thirteen.svg └── vercel.svg ├── services └── restaurant │ └── findAvailableTables.ts ├── tailwind.config.js ├── tsconfig.json └── utils ├── calculateReviewRatingAverage.ts └── convertToDisplayTime.ts /.env: -------------------------------------------------------------------------------- 1 | # Environment variables declared in this file are automatically made available to Prisma. 2 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 3 | 4 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 5 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 6 | 7 | DATABASE_URL="postgres://postgres:1SGUSUT5FT7ZdHRb@db.qtvrwtoesbghwpblhnxn.supabase.co:6543/postgres" 8 | JWT_SECRET="h82934ht08H*(#GR*(HF()#QHGF(#)Q*GH#(IQOPWGH#QP(GJH#(P" -------------------------------------------------------------------------------- /.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 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "typescript.enablePromptUseWorkspaceTsdk": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm run dev 9 | # or 10 | yarn dev 11 | ``` 12 | 13 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. 14 | 15 | You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. 16 | 17 | [API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`. 18 | 19 | The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages. 20 | 21 | This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. 22 | 23 | ## Learn More 24 | 25 | To learn more about Next.js, take a look at the following resources: 26 | 27 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. 28 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. 29 | 30 | You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! 31 | 32 | ## Deploy on Vercel 33 | 34 | The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. 35 | 36 | Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. 37 | -------------------------------------------------------------------------------- /app/components/AuthModal.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useEffect, useState, useContext } from "react"; 4 | import Box from "@mui/material/Box"; 5 | import Modal from "@mui/material/Modal"; 6 | import AuthModalInputs from "./AuthModalInputs"; 7 | import useAuth from "../../hooks/useAuth"; 8 | import { AuthenticationContext } from "../context/AuthContext"; 9 | import { Alert, CircularProgress } from "@mui/material"; 10 | 11 | const style = { 12 | position: "absolute" as "absolute", 13 | top: "50%", 14 | left: "50%", 15 | transform: "translate(-50%, -50%)", 16 | width: 400, 17 | bgcolor: "background.paper", 18 | 19 | boxShadow: 24, 20 | p: 4, 21 | }; 22 | 23 | export default function AuthModal({ isSignin }: { isSignin: boolean }) { 24 | const [open, setOpen] = useState(false); 25 | const handleOpen = () => setOpen(true); 26 | const handleClose = () => setOpen(false); 27 | const { signin, signup } = useAuth(); 28 | const { loading, data, error } = useContext(AuthenticationContext); 29 | 30 | const renderContent = (signinContent: string, signupContent: string) => { 31 | return isSignin ? signinContent : signupContent; 32 | }; 33 | 34 | const handleChangeInput = (e: React.ChangeEvent) => { 35 | setInputs({ 36 | ...inputs, 37 | [e.target.name]: e.target.value, 38 | }); 39 | }; 40 | 41 | const [inputs, setInputs] = useState({ 42 | firstName: "", 43 | lastName: "", 44 | email: "", 45 | phone: "", 46 | city: "", 47 | password: "", 48 | }); 49 | 50 | const [disabled, setDisabled] = useState(true); 51 | 52 | useEffect(() => { 53 | if (isSignin) { 54 | if (inputs.password && inputs.email) { 55 | return setDisabled(false); 56 | } 57 | } else { 58 | if ( 59 | inputs.firstName && 60 | inputs.lastName && 61 | inputs.email && 62 | inputs.password && 63 | inputs.city && 64 | inputs.phone 65 | ) { 66 | return setDisabled(false); 67 | } 68 | } 69 | 70 | setDisabled(true); 71 | }, [inputs]); 72 | 73 | const handleClick = () => { 74 | if (isSignin) { 75 | signin({ email: inputs.email, password: inputs.password }, handleClose); 76 | } else { 77 | signup(inputs, handleClose); 78 | } 79 | }; 80 | 81 | return ( 82 |
83 | 92 | 98 | 99 | {loading ? ( 100 |
101 | 102 |
103 | ) : ( 104 |
105 | {error ? ( 106 | 107 | {error} 108 | 109 | ) : null} 110 |
111 |

112 | {renderContent("Sign In", "Create Account")} 113 |

114 |
115 |
116 |

117 | {renderContent( 118 | "Log Into Your Account", 119 | "Create Your OpenTable Account" 120 | )} 121 |

122 | 127 | 134 |
135 |
136 | )} 137 |
138 |
139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/components/AuthModalInputs.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface Props { 4 | inputs: { 5 | firstName: string; 6 | lastName: string; 7 | email: string; 8 | phone: string; 9 | city: string; 10 | password: string; 11 | }; 12 | handleChangeInput: (e: React.ChangeEvent) => void; 13 | isSignin: boolean; 14 | } 15 | 16 | export default function AuthModalInputs({ 17 | inputs, 18 | handleChangeInput, 19 | isSignin, 20 | }: Props) { 21 | return ( 22 |
23 | {isSignin ? null : ( 24 |
25 | 33 | 41 |
42 | )} 43 |
44 | 52 |
53 | {isSignin ? null : ( 54 |
55 | 63 | 71 |
72 | )} 73 |
74 | 82 |
83 |
84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /app/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import SearchBar from "./SearchBar"; 2 | 3 | export default function Header() { 4 | return ( 5 |
6 |
7 |

8 | Find your table for any occasion 9 |

10 | 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/components/NavBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Link from "next/link"; 4 | import AuthModal from "./AuthModal"; 5 | import { useContext } from "react"; 6 | import { AuthenticationContext } from "../context/AuthContext"; 7 | import useAuth from "../../hooks/useAuth"; 8 | 9 | export default function NavBar() { 10 | const { data, loading } = useContext(AuthenticationContext); 11 | const { signout } = useAuth(); 12 | return ( 13 | 37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/components/Price.tsx: -------------------------------------------------------------------------------- 1 | import { PRICE } from "@prisma/client"; 2 | import React from "react"; 3 | 4 | export default function Price({ price }: { price: PRICE }) { 5 | const renderPrice = () => { 6 | if (price === PRICE.CHEAP) { 7 | return ( 8 | <> 9 | $$ $$ 10 | 11 | ); 12 | } else if (price === PRICE.REGULAR) { 13 | return ( 14 | <> 15 | $$$ $ 16 | 17 | ); 18 | } else { 19 | return ( 20 | <> 21 | $$$$ 22 | 23 | ); 24 | } 25 | }; 26 | 27 | return

{renderPrice()}

; 28 | } 29 | -------------------------------------------------------------------------------- /app/components/RestaurantCard.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import { RestaurantCardType } from "../page"; 3 | import Price from "./Price"; 4 | import Stars from "./Stars"; 5 | 6 | interface Props { 7 | restaurant: RestaurantCardType; 8 | } 9 | 10 | export default function RestaurantCard({ restaurant }: Props) { 11 | return ( 12 |
13 | 14 | 15 |
16 |

{restaurant.name}

17 |
18 | 19 |

20 | {restaurant.reviews.length} review 21 | {restaurant.reviews.length === 1 ? "" : "s"} 22 |

23 |
24 |
25 |

{restaurant.cuisine.name}

26 | 27 |

{restaurant.location.name}

28 |
29 |

Booked 3 times today

30 |
31 | 32 |
33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /app/components/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useRouter } from "next/navigation"; 4 | import { useState } from "react"; 5 | 6 | export default function SearchBar() { 7 | const router = useRouter(); 8 | const [location, setLocation] = useState(""); 9 | return ( 10 |
11 | setLocation(e.target.value)} 17 | /> 18 | 28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/components/Stars.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import fullStar from "../../public/icons/full-star.png"; 3 | import halfStar from "../../public/icons/half-star.png"; 4 | import emptyStar from "../../public/icons/empty-star.png"; 5 | import Image from "next/image"; 6 | import { Review } from "@prisma/client"; 7 | import { calculateReviewRatingAverage } from "../../utils/calculateReviewRatingAverage"; 8 | 9 | export default function Stars({ 10 | reviews, 11 | rating, 12 | }: { 13 | reviews: Review[]; 14 | rating?: number; 15 | }) { 16 | const reviewRating = rating || calculateReviewRatingAverage(reviews); 17 | 18 | const renderStars = () => { 19 | const stars = []; 20 | 21 | for (let i = 0; i < 5; i++) { 22 | const difference = parseFloat((reviewRating - i).toFixed(1)); 23 | if (difference >= 1) stars.push(fullStar); 24 | else if (difference < 1 && difference > 0) { 25 | if (difference <= 0.2) stars.push(emptyStar); 26 | else if (difference > 0.2 && difference <= 0.6) stars.push(halfStar); 27 | else stars.push(fullStar); 28 | } else stars.push(emptyStar); 29 | } 30 | 31 | return stars.map((star) => { 32 | return ; 33 | }); 34 | }; 35 | 36 | return
{renderStars()}
; 37 | } 38 | -------------------------------------------------------------------------------- /app/context/AuthContext.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import axios from "axios"; 4 | import { getCookie } from "cookies-next"; 5 | import React, { useState, createContext, useEffect } from "react"; 6 | 7 | interface User { 8 | id: number; 9 | firstName: string; 10 | lastName: string; 11 | email: string; 12 | city: string; 13 | phone: string; 14 | } 15 | 16 | interface State { 17 | loading: boolean; 18 | error: string | null; 19 | data: User | null; 20 | } 21 | 22 | interface AuthState extends State { 23 | setAuthState: React.Dispatch>; 24 | } 25 | 26 | export const AuthenticationContext = createContext({ 27 | loading: false, 28 | error: null, 29 | data: null, 30 | setAuthState: () => {}, 31 | }); 32 | 33 | export default function AuthContext({ 34 | children, 35 | }: { 36 | children: React.ReactNode; 37 | }) { 38 | const [authState, setAuthState] = useState({ 39 | loading: true, 40 | data: null, 41 | error: null, 42 | }); 43 | 44 | const fetchUser = async () => { 45 | setAuthState({ 46 | data: null, 47 | error: null, 48 | loading: true, 49 | }); 50 | try { 51 | const jwt = getCookie("jwt"); 52 | 53 | if (!jwt) { 54 | return setAuthState({ 55 | data: null, 56 | error: null, 57 | loading: false, 58 | }); 59 | } 60 | 61 | const response = await axios.get("http://localhost:3000/api/auth/me", { 62 | headers: { 63 | Authorization: `Bearer ${jwt}`, 64 | }, 65 | }); 66 | 67 | axios.defaults.headers.common["Authorization"] = `Bearer ${jwt}`; 68 | 69 | setAuthState({ 70 | data: response.data, 71 | error: null, 72 | loading: false, 73 | }); 74 | } catch (error: any) { 75 | setAuthState({ 76 | data: null, 77 | error: error.response.data.errorMessage, 78 | loading: false, 79 | }); 80 | } 81 | }; 82 | 83 | useEffect(() => { 84 | fetchUser(); 85 | }, []); 86 | 87 | return ( 88 | 94 | {children} 95 | 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --max-width: 1100px; 7 | --border-radius: 12px; 8 | --font-mono: ui-monospace, Menlo, Monaco, 'Cascadia Mono', 'Segoe UI Mono', 9 | 'Roboto Mono', 'Oxygen Mono', 'Ubuntu Monospace', 'Source Code Pro', 10 | 'Fira Mono', 'Droid Sans Mono', 'Courier New', monospace; 11 | 12 | --foreground-rgb: 0, 0, 0; 13 | --background-start-rgb: 214, 219, 220; 14 | --background-end-rgb: 255, 255, 255; 15 | 16 | --primary-glow: conic-gradient( 17 | from 180deg at 50% 50%, 18 | #16abff33 0deg, 19 | #0885ff33 55deg, 20 | #54d6ff33 120deg, 21 | #0071ff33 160deg, 22 | transparent 360deg 23 | ); 24 | --secondary-glow: radial-gradient( 25 | rgba(255, 255, 255, 1), 26 | rgba(255, 255, 255, 0) 27 | ); 28 | 29 | --tile-start-rgb: 239, 245, 249; 30 | --tile-end-rgb: 228, 232, 233; 31 | --tile-border: conic-gradient( 32 | #00000080, 33 | #00000040, 34 | #00000030, 35 | #00000020, 36 | #00000010, 37 | #00000010, 38 | #00000080 39 | ); 40 | 41 | --callout-rgb: 238, 240, 241; 42 | --callout-border-rgb: 172, 175, 176; 43 | --card-rgb: 180, 185, 188; 44 | --card-border-rgb: 131, 134, 135; 45 | } 46 | 47 | @media (prefers-color-scheme: dark) { 48 | :root { 49 | --foreground-rgb: 255, 255, 255; 50 | --background-start-rgb: 0, 0, 0; 51 | --background-end-rgb: 0, 0, 0; 52 | 53 | --primary-glow: radial-gradient(rgba(1, 65, 255, 0.4), rgba(1, 65, 255, 0)); 54 | --secondary-glow: linear-gradient( 55 | to bottom right, 56 | rgba(1, 65, 255, 0), 57 | rgba(1, 65, 255, 0), 58 | rgba(1, 65, 255, 0.3) 59 | ); 60 | 61 | --tile-start-rgb: 2, 13, 46; 62 | --tile-end-rgb: 2, 5, 19; 63 | --tile-border: conic-gradient( 64 | #ffffff80, 65 | #ffffff40, 66 | #ffffff30, 67 | #ffffff20, 68 | #ffffff10, 69 | #ffffff10, 70 | #ffffff80 71 | ); 72 | 73 | --callout-rgb: 20, 20, 20; 74 | --callout-border-rgb: 108, 108, 108; 75 | --card-rgb: 100, 100, 100; 76 | --card-border-rgb: 200, 200, 200; 77 | } 78 | } 79 | 80 | * { 81 | box-sizing: border-box; 82 | padding: 0; 83 | margin: 0; 84 | } 85 | 86 | html, 87 | body { 88 | max-width: 100vw; 89 | overflow-x: hidden; 90 | } 91 | 92 | body { 93 | color: rgb(var(--foreground-rgb)); 94 | background: linear-gradient( 95 | to bottom, 96 | transparent, 97 | rgb(var(--background-end-rgb)) 98 | ) 99 | rgb(var(--background-start-rgb)); 100 | } 101 | 102 | a { 103 | color: inherit; 104 | text-decoration: none; 105 | } 106 | 107 | @media (prefers-color-scheme: dark) { 108 | html { 109 | color-scheme: dark; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /app/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | OpenTable 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import NavBar from "./components/NavBar"; 2 | import AuthContext from "./context/AuthContext"; 3 | import "./globals.css"; 4 | import "react-datepicker/dist/react-datepicker.css"; 5 | export default function RootLayout({ 6 | children, 7 | }: { 8 | children: React.ReactNode; 9 | }) { 10 | return ( 11 | 12 | {/* 13 | will contain the components returned by the nearest parent 14 | head.tsx. Find out more at https://beta.nextjs.org/docs/api-reference/file-conventions/head 15 | */} 16 | 17 | 18 |
19 | 20 |
21 | 22 | {children} 23 |
24 |
25 |
26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Header from "./components/Header"; 3 | 4 | export default function Loading() { 5 | return ( 6 |
7 |
8 |
9 | {[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((num) => ( 10 |
14 | ))} 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/page.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: space-between; 5 | align-items: center; 6 | padding: 6rem; 7 | min-height: 100vh; 8 | } 9 | 10 | .description { 11 | display: inherit; 12 | justify-content: inherit; 13 | align-items: inherit; 14 | font-size: 0.85rem; 15 | max-width: var(--max-width); 16 | width: 100%; 17 | z-index: 2; 18 | font-family: var(--font-mono); 19 | } 20 | 21 | .description a { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | gap: 0.5rem; 26 | } 27 | 28 | .description p { 29 | position: relative; 30 | margin: 0; 31 | padding: 1rem; 32 | background-color: rgba(var(--callout-rgb), 0.5); 33 | border: 1px solid rgba(var(--callout-border-rgb), 0.3); 34 | border-radius: var(--border-radius); 35 | } 36 | 37 | .code { 38 | font-weight: 700; 39 | font-family: var(--font-mono); 40 | } 41 | 42 | .grid { 43 | display: grid; 44 | grid-template-columns: repeat(3, minmax(33%, auto)); 45 | width: var(--max-width); 46 | max-width: 100%; 47 | } 48 | 49 | .card { 50 | padding: 1rem 1.2rem; 51 | border-radius: var(--border-radius); 52 | background: rgba(var(--card-rgb), 0); 53 | border: 1px solid rgba(var(--card-border-rgb), 0); 54 | transition: background 200ms, border 200ms; 55 | } 56 | 57 | .card span { 58 | display: inline-block; 59 | transition: transform 200ms; 60 | } 61 | 62 | .card h2 { 63 | font-weight: 600; 64 | margin-bottom: 0.7rem; 65 | } 66 | 67 | .card p { 68 | margin: 0; 69 | opacity: 0.6; 70 | font-size: 0.9rem; 71 | line-height: 1.5; 72 | max-width: 34ch; 73 | } 74 | 75 | .center { 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | position: relative; 80 | padding: 4rem 0; 81 | } 82 | 83 | .center::before { 84 | background: var(--secondary-glow); 85 | border-radius: 50%; 86 | width: 480px; 87 | height: 360px; 88 | margin-left: -400px; 89 | } 90 | 91 | .center::after { 92 | background: var(--primary-glow); 93 | width: 240px; 94 | height: 180px; 95 | z-index: -1; 96 | } 97 | 98 | .center::before, 99 | .center::after { 100 | content: ''; 101 | left: 50%; 102 | position: absolute; 103 | filter: blur(45px); 104 | transform: translateZ(0); 105 | } 106 | 107 | .logo, 108 | .thirteen { 109 | position: relative; 110 | } 111 | 112 | .thirteen { 113 | display: flex; 114 | justify-content: center; 115 | align-items: center; 116 | width: 75px; 117 | height: 75px; 118 | padding: 25px 10px; 119 | margin-left: 16px; 120 | transform: translateZ(0); 121 | border-radius: var(--border-radius); 122 | overflow: hidden; 123 | box-shadow: 0px 2px 8px -1px #0000001a; 124 | } 125 | 126 | .thirteen::before, 127 | .thirteen::after { 128 | content: ''; 129 | position: absolute; 130 | z-index: -1; 131 | } 132 | 133 | /* Conic Gradient Animation */ 134 | .thirteen::before { 135 | animation: 6s rotate linear infinite; 136 | width: 200%; 137 | height: 200%; 138 | background: var(--tile-border); 139 | } 140 | 141 | /* Inner Square */ 142 | .thirteen::after { 143 | inset: 0; 144 | padding: 1px; 145 | border-radius: var(--border-radius); 146 | background: linear-gradient( 147 | to bottom right, 148 | rgba(var(--tile-start-rgb), 1), 149 | rgba(var(--tile-end-rgb), 1) 150 | ); 151 | background-clip: content-box; 152 | } 153 | 154 | /* Enable hover only on non-touch devices */ 155 | @media (hover: hover) and (pointer: fine) { 156 | .card:hover { 157 | background: rgba(var(--card-rgb), 0.1); 158 | border: 1px solid rgba(var(--card-border-rgb), 0.15); 159 | } 160 | 161 | .card:hover span { 162 | transform: translateX(4px); 163 | } 164 | } 165 | 166 | @media (prefers-reduced-motion) { 167 | .thirteen::before { 168 | animation: none; 169 | } 170 | 171 | .card:hover span { 172 | transform: none; 173 | } 174 | } 175 | 176 | /* Mobile and Tablet */ 177 | @media (max-width: 1023px) { 178 | .content { 179 | padding: 4rem; 180 | } 181 | 182 | .grid { 183 | grid-template-columns: 1fr; 184 | margin-bottom: 120px; 185 | max-width: 320px; 186 | text-align: center; 187 | } 188 | 189 | .card { 190 | padding: 1rem 2.5rem; 191 | } 192 | 193 | .card h2 { 194 | margin-bottom: 0.5rem; 195 | } 196 | 197 | .center { 198 | padding: 8rem 0 6rem; 199 | } 200 | 201 | .center::before { 202 | transform: none; 203 | height: 300px; 204 | } 205 | 206 | .description { 207 | font-size: 0.8rem; 208 | } 209 | 210 | .description a { 211 | padding: 1rem; 212 | } 213 | 214 | .description p, 215 | .description div { 216 | display: flex; 217 | justify-content: center; 218 | position: fixed; 219 | width: 100%; 220 | } 221 | 222 | .description p { 223 | align-items: center; 224 | inset: 0 0 auto; 225 | padding: 2rem 1rem 1.4rem; 226 | border-radius: 0; 227 | border: none; 228 | border-bottom: 1px solid rgba(var(--callout-border-rgb), 0.25); 229 | background: linear-gradient( 230 | to bottom, 231 | rgba(var(--background-start-rgb), 1), 232 | rgba(var(--callout-rgb), 0.5) 233 | ); 234 | background-clip: padding-box; 235 | backdrop-filter: blur(24px); 236 | } 237 | 238 | .description div { 239 | align-items: flex-end; 240 | pointer-events: none; 241 | inset: auto 0 0; 242 | padding: 2rem; 243 | height: 200px; 244 | background: linear-gradient( 245 | to bottom, 246 | transparent 0%, 247 | rgb(var(--background-end-rgb)) 40% 248 | ); 249 | z-index: 1; 250 | } 251 | } 252 | 253 | @media (prefers-color-scheme: dark) { 254 | .vercelLogo { 255 | filter: invert(1); 256 | } 257 | 258 | .logo, 259 | .thirteen img { 260 | filter: invert(1) drop-shadow(0 0 0.3rem #ffffff70); 261 | } 262 | } 263 | 264 | @keyframes rotate { 265 | from { 266 | transform: rotate(360deg); 267 | } 268 | to { 269 | transform: rotate(0deg); 270 | } 271 | } 272 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import Header from "./components/Header"; 2 | import RestaurantCard from "./components/RestaurantCard"; 3 | import { PrismaClient, Cuisine, Location, PRICE, Review } from "@prisma/client"; 4 | 5 | export interface RestaurantCardType { 6 | id: number; 7 | name: string; 8 | main_image: string; 9 | cuisine: Cuisine; 10 | location: Location; 11 | price: PRICE; 12 | slug: string; 13 | reviews: Review[]; 14 | } 15 | 16 | const prisma = new PrismaClient(); 17 | 18 | const fetchRestaurants = async (): Promise => { 19 | const restaurants = await prisma.restaurant.findMany({ 20 | select: { 21 | id: true, 22 | name: true, 23 | main_image: true, 24 | cuisine: true, 25 | slug: true, 26 | location: true, 27 | price: true, 28 | reviews: true, 29 | }, 30 | }); 31 | 32 | return restaurants; 33 | }; 34 | 35 | export default async function Home() { 36 | const restaurants = await fetchRestaurants(); 37 | 38 | return ( 39 |
40 |
41 |
42 | {restaurants.map((restaurant) => ( 43 | 44 | ))} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /app/reserve/[slug]/components/Form.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { CircularProgress } from "@mui/material"; 4 | import { useEffect, useState } from "react"; 5 | import useReservation from "../../../../hooks/useReservation"; 6 | 7 | export default function Form({ 8 | slug, 9 | date, 10 | partySize, 11 | }: { 12 | slug: string; 13 | date: string; 14 | partySize: string; 15 | }) { 16 | const [inputs, setInputs] = useState({ 17 | bookerFirstName: "", 18 | bookerLastName: "", 19 | bookerPhone: "", 20 | bookerEmail: "", 21 | bookerOccasion: "", 22 | bookerRequest: "", 23 | }); 24 | const [day, time] = date.split("T"); 25 | const [disabled, setDisabled] = useState(true); 26 | const [didBook, setDidBook] = useState(false); 27 | const { error, loading, createReservation } = useReservation(); 28 | 29 | useEffect(() => { 30 | if ( 31 | inputs.bookerFirstName && 32 | inputs.bookerLastName && 33 | inputs.bookerEmail && 34 | inputs.bookerPhone 35 | ) { 36 | return setDisabled(false); 37 | } 38 | return setDisabled(true); 39 | }, [inputs]); 40 | 41 | const handleChangeInput = (e: React.ChangeEvent) => { 42 | setInputs({ 43 | ...inputs, 44 | [e.target.name]: e.target.value, 45 | }); 46 | }; 47 | 48 | const handleClick = async () => { 49 | const booking = await createReservation({ 50 | slug, 51 | partySize, 52 | time, 53 | day, 54 | bookerFirstName: inputs.bookerFirstName, 55 | bookerLastName: inputs.bookerLastName, 56 | bookerEmail: inputs.bookerEmail, 57 | bookerOccasion: inputs.bookerOccasion, 58 | bookerPhone: inputs.bookerPhone, 59 | bookerRequest: inputs.bookerRequest, 60 | setDidBook, 61 | }); 62 | }; 63 | 64 | return ( 65 |
66 | {didBook ? ( 67 |
68 |

You are all booked up

69 |

Enjoy your reservation

70 |
71 | ) : ( 72 | <> 73 | 81 | 89 | 97 | 105 | 113 | 121 | 132 |

133 | By clicking “Complete reservation” you agree to the OpenTable Terms 134 | of Use and Privacy Policy. Standard text message rates may apply. 135 | You may opt out of receiving text messages at any time. 136 |

137 | 138 | )} 139 |
140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /app/reserve/[slug]/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | convertToDisplayTime, 3 | Time, 4 | } from "../../../../utils/convertToDisplayTime"; 5 | import { format } from "date-fns"; 6 | 7 | export default function Header({ 8 | image, 9 | name, 10 | date, 11 | partySize, 12 | }: { 13 | image: string; 14 | name: string; 15 | date: string; 16 | partySize: string; 17 | }) { 18 | const [day, time] = date.split("T"); 19 | 20 | return ( 21 |
22 |

You're almost done!

23 |
24 | 25 |
26 |

{name}

27 |
28 |

{format(new Date(date), "ccc, LLL d")}

29 |

{convertToDisplayTime(time as Time)}

30 |

31 | {partySize} {parseInt(partySize) === 1 ? "person" : "people"} 32 |

33 |
34 |
35 |
36 |
37 | ); 38 | } 39 | -------------------------------------------------------------------------------- /app/reserve/[slug]/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Reserve at Milestones Grill (Toronto) | OpenTable 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | // SELECT * FROM restaurant WHERE id = 4 12 | // prisma.restuant.findUnique(4) 13 | // 1SGUSUT5FT7ZdHRb 14 | -------------------------------------------------------------------------------- /app/reserve/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { notFound } from "next/navigation"; 3 | import Form from "./components/Form"; 4 | import Header from "./components/Header"; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | const fetchRestaurantBySlug = async (slug: string) => { 9 | const restaurant = await prisma.restaurant.findUnique({ 10 | where: { 11 | slug, 12 | }, 13 | }); 14 | 15 | if (!restaurant) { 16 | notFound(); 17 | } 18 | 19 | return restaurant; 20 | }; 21 | 22 | export default async function Reserve({ 23 | params, 24 | searchParams, 25 | }: { 26 | params: { slug: string }; 27 | searchParams: { date: string; partySize: string }; 28 | }) { 29 | const restaurant = await fetchRestaurantBySlug(params.slug); 30 | return ( 31 |
32 |
33 |
39 |
44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Description.tsx: -------------------------------------------------------------------------------- 1 | export default function Description({ description }: { description: string }) { 2 | return ( 3 |
4 |

{description}

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Header.tsx: -------------------------------------------------------------------------------- 1 | export default function Header({ name }: { name: string }) { 2 | const renderTitle = () => { 3 | const nameArray = name.split("-"); 4 | 5 | nameArray[nameArray.length - 1] = `(${nameArray[nameArray.length - 1]})`; 6 | 7 | return nameArray.join(" "); 8 | }; 9 | 10 | return ( 11 |
12 |
13 |

14 | {renderTitle()} 15 |

16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Images.tsx: -------------------------------------------------------------------------------- 1 | export default function Images({ images }: { images: string[] }) { 2 | return ( 3 |
4 |

5 | {images.length} photo{images.length > 1 ? "s" : ""} 6 |

7 |
8 | {images.map((image) => ( 9 | 10 | ))} 11 |
12 |
13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Menu.tsx: -------------------------------------------------------------------------------- 1 | import { Item } from "@prisma/client"; 2 | import React from "react"; 3 | import MenuCard from "./MenuCard"; 4 | 5 | export default function Menu({ menu }: { menu: Item[] }) { 6 | return ( 7 |
8 |
9 |
10 |

Menu

11 |
12 | {menu.length ? ( 13 |
14 | {menu.map((item) => ( 15 | 16 | ))} 17 |
18 | ) : ( 19 |
20 |

This restaurant does not have a menu

21 |
22 | )} 23 |
24 |
25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/MenuCard.tsx: -------------------------------------------------------------------------------- 1 | import { Item } from "@prisma/client"; 2 | 3 | export default function MenuCard({ item }: { item: Item }) { 4 | return ( 5 |
6 |

{item.name}

7 |

{item.description}

8 |

{item.price}

9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Rating.tsx: -------------------------------------------------------------------------------- 1 | import { Review } from "@prisma/client"; 2 | import { calculateReviewRatingAverage } from "../../../../utils/calculateReviewRatingAverage"; 3 | import Stars from "../../../components/Stars"; 4 | 5 | export default function Rating({ reviews }: { reviews: Review[] }) { 6 | return ( 7 |
8 |
9 | 10 |

11 | {calculateReviewRatingAverage(reviews).toFixed(1)} 12 |

13 |
14 |
15 |

16 | {reviews.length} Review{reviews.length === 1 ? "" : "s"} 17 |

18 |
19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/ReservationCard.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { partySize as partySizes, times } from "../../../../data"; 3 | import DatePicker from "react-datepicker"; 4 | import { useState } from "react"; 5 | import useAvailabilities from "../../../../hooks/useAvailabilities"; 6 | import { CircularProgress } from "@mui/material"; 7 | import Link from "next/link"; 8 | import { 9 | convertToDisplayTime, 10 | Time, 11 | } from "../../../../utils/convertToDisplayTime"; 12 | 13 | export default function ReservationCard({ 14 | openTime, 15 | closeTime, 16 | slug, 17 | }: { 18 | openTime: string; 19 | closeTime: string; 20 | slug: string; 21 | }) { 22 | const { data, loading, error, fetchAvailabilities } = useAvailabilities(); 23 | const [selectedDate, setSelectedDate] = useState(new Date()); 24 | const [time, setTime] = useState(openTime); 25 | const [partySize, setPartySize] = useState("2"); 26 | const [day, setDay] = useState(new Date().toISOString().split("T")[0]); 27 | 28 | const handleChangeDate = (date: Date | null) => { 29 | if (date) { 30 | setDay(date.toISOString().split("T")[0]); 31 | return setSelectedDate(date); 32 | } 33 | return setSelectedDate(null); 34 | }; 35 | 36 | const handleClick = () => { 37 | fetchAvailabilities({ 38 | slug, 39 | day, 40 | time, 41 | partySize, 42 | }); 43 | }; 44 | 45 | const filterTimeByRestaurantOpenWindow = () => { 46 | const timesWithinWindow: typeof times = []; 47 | 48 | let isWithinWindow = false; 49 | 50 | times.forEach((time) => { 51 | if (time.time === openTime) { 52 | isWithinWindow = true; 53 | } 54 | if (isWithinWindow) { 55 | timesWithinWindow.push(time); 56 | } 57 | if (time.time === closeTime) { 58 | isWithinWindow = false; 59 | } 60 | }); 61 | 62 | return timesWithinWindow; 63 | }; 64 | 65 | return ( 66 |
67 |
68 |

Make a Reservation

69 |
70 |
71 | 72 | 83 |
84 |
85 |
86 | 87 | 94 |
95 |
96 | 97 | 108 |
109 |
110 |
111 | 118 |
119 | {data && data.length ? ( 120 |
121 |

Select a Time

122 |
123 | {data.map((time) => { 124 | return time.available ? ( 125 | 129 |

130 | {convertToDisplayTime(time.time as Time)} 131 |

132 | 133 | ) : ( 134 |

135 | ); 136 | })} 137 |
138 |
139 | ) : null} 140 |
141 | ); 142 | } 143 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/RestaurantNavBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | 3 | export default function RestaurantNavBar({ slug }: { slug: string }) { 4 | return ( 5 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/ReviewCard.tsx: -------------------------------------------------------------------------------- 1 | import { Review } from "@prisma/client"; 2 | import React from "react"; 3 | import Stars from "../../../components/Stars"; 4 | 5 | export default function ReviewCard({ review }: { review: Review }) { 6 | return ( 7 |
8 |
9 |
10 |
11 |

12 | {review.first_name[0]} 13 | {review.last_name[0]} 14 |

15 |
16 |

17 | {review.first_name} {review.last_name} 18 |

19 |
20 |
21 |
22 |
23 | 24 |
25 |
26 |
27 |

{review.text}

28 |
29 |
30 |
31 |
32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Reviews.tsx: -------------------------------------------------------------------------------- 1 | import { Review } from "@prisma/client"; 2 | import ReviewCard from "./ReviewCard"; 3 | 4 | export default function Reviews({ reviews }: { reviews: Review[] }) { 5 | return ( 6 |
7 |

8 | What {reviews.length} {reviews.length === 1 ? "person" : "people"} are 9 | saying 10 |

11 |
12 | {reviews.map((review) => ( 13 | 14 | ))} 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/components/Title.tsx: -------------------------------------------------------------------------------- 1 | export default function Title({ name }: { name: string }) { 2 | return ( 3 |
4 |

{name}

5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Milestones Grill (Toronto) | OpenTable 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/layout.tsx: -------------------------------------------------------------------------------- 1 | import Header from "./components/Header"; 2 | 3 | export default function RestaurantLayout({ 4 | children, 5 | params, 6 | }: { 7 | children: React.ReactNode; 8 | params: { slug: string }; 9 | }) { 10 | return ( 11 |
12 |
13 |
14 | {children} 15 |
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/menu/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | Menu of Milestones Grill (Toronto) | OpenTable 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/menu/page.tsx: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import Menu from "../components/Menu"; 3 | import RestaurantNavBar from "../components/RestaurantNavBar"; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | const fetchRestaurantMenu = async (slug: string) => { 8 | const restaurant = await prisma.restaurant.findUnique({ 9 | where: { 10 | slug, 11 | }, 12 | select: { 13 | items: true, 14 | }, 15 | }); 16 | 17 | if (!restaurant) { 18 | throw new Error(); 19 | } 20 | 21 | return restaurant.items; 22 | }; 23 | 24 | export default async function RestaurantMenu({ 25 | params, 26 | }: { 27 | params: { slug: string }; 28 | }) { 29 | const menu = await fetchRestaurantMenu(params.slug); 30 | 31 | return ( 32 | <> 33 |
34 | 35 | 36 |
37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /app/restaurant/[slug]/page.tsx: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Review } from "@prisma/client"; 2 | import { notFound } from "next/navigation"; 3 | import Description from "./components/Description"; 4 | import Images from "./components/Images"; 5 | import Rating from "./components/Rating"; 6 | import ReservationCard from "./components/ReservationCard"; 7 | import RestaurantNavBar from "./components/RestaurantNavBar"; 8 | import Reviews from "./components/Reviews"; 9 | import Title from "./components/Title"; 10 | 11 | const prisma = new PrismaClient(); 12 | 13 | interface Restaurant { 14 | id: number; 15 | name: string; 16 | images: string[]; 17 | description: string; 18 | open_time: string; 19 | close_time: string; 20 | slug: string; 21 | reviews: Review[]; 22 | } 23 | 24 | const fetchRestaurantBySlug = async (slug: string): Promise => { 25 | const restaurant = await prisma.restaurant.findUnique({ 26 | where: { 27 | slug, 28 | }, 29 | select: { 30 | id: true, 31 | name: true, 32 | images: true, 33 | description: true, 34 | slug: true, 35 | reviews: true, 36 | open_time: true, 37 | close_time: true, 38 | }, 39 | }); 40 | 41 | if (!restaurant) { 42 | notFound(); 43 | } 44 | 45 | return restaurant; 46 | }; 47 | 48 | export default async function RestaurantDetails({ 49 | params, 50 | }: { 51 | params: { slug: string }; 52 | }) { 53 | const restaurant = await fetchRestaurantBySlug(params.slug); 54 | 55 | return ( 56 | <> 57 |
58 | 59 | 60 | <Rating reviews={restaurant.reviews} /> 61 | <Description description={restaurant.description} /> 62 | <Images images={restaurant.images} /> 63 | <Reviews reviews={restaurant.reviews} /> 64 | </div> 65 | <div className="w-[27%] relative text-reg"> 66 | <ReservationCard 67 | openTime={restaurant.open_time} 68 | closeTime={restaurant.close_time} 69 | slug={restaurant.slug} 70 | /> 71 | </div> 72 | </> 73 | ); 74 | } 75 | -------------------------------------------------------------------------------- /app/restaurant/error.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import errorMascot from "../../public/icons/error.png"; 5 | 6 | export default function Error({ error }: { error: Error }) { 7 | return ( 8 | <div className="h-screen bg-gray-200 flex flex-col justify-center items-center"> 9 | <Image src={errorMascot} alt="error" className="w-56 mb-8" /> 10 | <div className="bg-white px-9 py-14 shadow rounded"> 11 | <h3 className="text-3xl font-bold">Well, this is embarrassing</h3> 12 | <p className="text-reg font-bold">{error.message}</p> 13 | <p className="mt-6 text-sm font-light">Error Code: 400</p> 14 | </div> 15 | </div> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/restaurant/loading.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default function Loading() { 4 | return ( 5 | <main> 6 | <div className="h-96 overflow-hidden animate-pulse bg-slate-200"> 7 | <div className={`bg-center h-full`} /> 8 | </div> 9 | <div className="flex m-auto w-2/3 justify-between items-start 0 -mt-9"> 10 | <div className="bg-white w-[70%] rounded p-3 shadow"> 11 | <nav className="flex text-reg border-b pb-2"> 12 | <h4 className="mr-7">Overview</h4> 13 | <p className="mr-7">Menu</p> 14 | </nav> 15 | 16 | <div className="mt-4 border-b pb-6 animate-pulse bg-slate-200 w-[400px] h-16 rounded"></div> 17 | 18 | <div className="flex items-end animate-pulse"> 19 | <div className="ratings mt-2 flex items-center"> 20 | <div className="flex items-center bg-slate-200 w-56"></div> 21 | <p className="text-reg ml-3"></p> 22 | </div> 23 | <div> 24 | <p className="text-reg ml-1 ml-4"> </p> 25 | </div> 26 | </div> 27 | </div> 28 | </div> 29 | </main> 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /app/restaurant/not-found.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import Image from "next/image"; 4 | import errorMascot from "../../public/icons/error.png"; 5 | 6 | export default function Error({ error }: { error: Error }) { 7 | return ( 8 | <div className="h-screen bg-gray-200 flex flex-col justify-center items-center"> 9 | <Image src={errorMascot} alt="error" className="w-56 mb-8" /> 10 | <div className="bg-white px-9 py-14 shadow rounded"> 11 | <h3 className="text-3xl font-bold">Well, this is embarrassing</h3> 12 | <p className="text-reg font-bold">We couldn't find that restaurant</p> 13 | <p className="mt-6 text-sm font-light">Error Code: 404</p> 14 | </div> 15 | </div> 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /app/search/components/Header.tsx: -------------------------------------------------------------------------------- 1 | import SearchBar from "../../components/SearchBar"; 2 | 3 | export default function Header() { 4 | return ( 5 | <div className="bg-gradient-to-r to-[#5f6984] from-[#0f1f47] p-2"> 6 | <SearchBar /> 7 | </div> 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /app/search/components/RestaurantCard.tsx: -------------------------------------------------------------------------------- 1 | import { Cuisine, PRICE, Location, Review } from "@prisma/client"; 2 | import Link from "next/link"; 3 | import { calculateReviewRatingAverage } from "../../../utils/calculateReviewRatingAverage"; 4 | import Price from "../../components/Price"; 5 | import Stars from "../../components/Stars"; 6 | 7 | interface Restaurant { 8 | id: number; 9 | name: string; 10 | main_image: string; 11 | price: PRICE; 12 | cuisine: Cuisine; 13 | location: Location; 14 | slug: string; 15 | reviews: Review[]; 16 | } 17 | 18 | export default function RestaurantCard({ 19 | restaurant, 20 | }: { 21 | restaurant: Restaurant; 22 | }) { 23 | const renderRatingText = () => { 24 | const rating = calculateReviewRatingAverage(restaurant.reviews); 25 | 26 | if (rating > 4) return "Awesome"; 27 | else if (rating <= 4 && rating > 3) return "Good"; 28 | else if (rating <= 3 && rating > 0) return "Average"; 29 | else ""; 30 | }; 31 | 32 | return ( 33 | <div className="border-b flex pb-5 ml-4"> 34 | <img src={restaurant.main_image} alt="" className="w-44 h-36 rounded" /> 35 | <div className="pl-5"> 36 | <h2 className="text-3xl">{restaurant.name}</h2> 37 | <div className="flex items-start"> 38 | <div className="flex mb-2"> 39 | <Stars reviews={restaurant.reviews} /> 40 | </div> 41 | <p className="ml-2 text-sm">{renderRatingText()}</p> 42 | </div> 43 | <div className="mb-9"> 44 | <div className="font-light flex text-reg"> 45 | <Price price={restaurant.price} /> 46 | <p className="mr-4 capitalize">{restaurant.cuisine.name}</p> 47 | <p className="mr-4 capitalize">{restaurant.location.name}</p> 48 | </div> 49 | </div> 50 | <div className="text-red-600"> 51 | <Link href={`/restaurant/${restaurant.slug}`}> 52 | View more information 53 | </Link> 54 | </div> 55 | </div> 56 | </div> 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/search/components/SearchSideBar.tsx: -------------------------------------------------------------------------------- 1 | import { Cuisine, Location, PRICE } from "@prisma/client"; 2 | import Link from "next/link"; 3 | 4 | export default function SearchSideBar({ 5 | locations, 6 | cuisines, 7 | searchParams, 8 | }: { 9 | locations: Location[]; 10 | cuisines: Cuisine[]; 11 | searchParams: { city?: string; cuisine?: string; price?: PRICE }; 12 | }) { 13 | const prices = [ 14 | { 15 | price: PRICE.CHEAP, 16 | label: "$", 17 | className: "border w-full text-reg text-center font-light rounded-l p-2", 18 | }, 19 | { 20 | price: PRICE.REGULAR, 21 | label: "$$", 22 | className: "border w-full text-reg text-center font-light p-2", 23 | }, 24 | { 25 | price: PRICE.EXPENSIVE, 26 | label: "$$$", 27 | className: "border w-full text-reg text-center font-light rounded-r p-2", 28 | }, 29 | ]; 30 | 31 | return ( 32 | <div className="w-1/5"> 33 | <div className="border-b pb-4 flex flex-col"> 34 | <h1 className="mb-2">Region</h1> 35 | {locations.map((location) => ( 36 | <Link 37 | href={{ 38 | pathname: "/search", 39 | query: { 40 | ...searchParams, 41 | city: location.name, 42 | }, 43 | }} 44 | className="font-light text-reg capitalize" 45 | key={location.id} 46 | > 47 | {location.name} 48 | </Link> 49 | ))} 50 | </div> 51 | <div className="border-b pb-4 mt-3 flex flex-col"> 52 | <h1 className="mb-2">Cuisine</h1> 53 | {cuisines.map((cuisine) => ( 54 | <Link 55 | href={{ 56 | pathname: "/search", 57 | query: { 58 | ...searchParams, 59 | cuisine: cuisine.name, 60 | }, 61 | }} 62 | className="font-light text-reg capitalize" 63 | key={cuisine.id} 64 | > 65 | {cuisine.name} 66 | </Link> 67 | ))} 68 | </div> 69 | <div className="mt-3 pb-4"> 70 | <h1 className="mb-2">Price</h1> 71 | <div className="flex"> 72 | {prices.map(({ price, label, className }) => ( 73 | <Link 74 | href={{ 75 | pathname: "/search", 76 | query: { 77 | ...searchParams, 78 | price, 79 | }, 80 | }} 81 | className={className} 82 | > 83 | {label} 84 | </Link> 85 | ))} 86 | </div> 87 | </div> 88 | </div> 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /app/search/head.tsx: -------------------------------------------------------------------------------- 1 | export default function Head() { 2 | return ( 3 | <> 4 | <title>Search Restaurants OpenTable 5 | 6 | 7 | 8 | 9 | ); 10 | } 11 | -------------------------------------------------------------------------------- /app/search/page.tsx: -------------------------------------------------------------------------------- 1 | import { PRICE, PrismaClient } from "@prisma/client"; 2 | import Header from "./components/Header"; 3 | import RestaurantCard from "./components/RestaurantCard"; 4 | import SearchSideBar from "./components/SearchSideBar"; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | interface SearchParams { 9 | city?: string; 10 | cuisine?: string; 11 | price?: PRICE; 12 | } 13 | 14 | const fetchRestaurantsByCity = (searchParams: SearchParams) => { 15 | const where: any = {}; 16 | 17 | if (searchParams.city) { 18 | const location = { 19 | name: { 20 | equals: searchParams.city.toLowerCase(), 21 | }, 22 | }; 23 | where.location = location; 24 | } 25 | if (searchParams.cuisine) { 26 | const cuisine = { 27 | name: { 28 | equals: searchParams.cuisine.toLowerCase(), 29 | }, 30 | }; 31 | where.cuisine = cuisine; 32 | } 33 | if (searchParams.price) { 34 | const price = { 35 | equals: searchParams.price, 36 | }; 37 | where.price = price; 38 | } 39 | 40 | const select = { 41 | id: true, 42 | name: true, 43 | main_image: true, 44 | price: true, 45 | cuisine: true, 46 | location: true, 47 | slug: true, 48 | reviews: true, 49 | }; 50 | 51 | return prisma.restaurant.findMany({ 52 | where, 53 | select, 54 | }); 55 | }; 56 | 57 | const fetchLocations = async () => { 58 | return prisma.location.findMany(); 59 | }; 60 | 61 | const fetchCuisines = async () => { 62 | return prisma.cuisine.findMany(); 63 | }; 64 | 65 | export default async function Search({ 66 | searchParams, 67 | }: { 68 | searchParams: SearchParams; 69 | }) { 70 | const restaurants = await fetchRestaurantsByCity(searchParams); 71 | const location = await fetchLocations(); 72 | const cuisine = await fetchCuisines(); 73 | return ( 74 | <> 75 |
76 |
77 | 82 |
83 | {restaurants.length ? ( 84 | <> 85 | {restaurants.map((restaurant) => ( 86 | 87 | ))} 88 | 89 | ) : ( 90 |

Sorry, we found no restaurants in this area

91 | )} 92 |
93 |
94 | 95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /data/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./partySize" 2 | export * from "./times" -------------------------------------------------------------------------------- /data/partySize.ts: -------------------------------------------------------------------------------- 1 | export const partySize = [ 2 | { 3 | value: 1, 4 | label: "1 person" 5 | }, 6 | { 7 | value: 2, 8 | label: "2 people" 9 | }, 10 | { 11 | value: 3, 12 | label: "3 people" 13 | }, 14 | { 15 | value: 4, 16 | label: "4 people" 17 | }, 18 | { 19 | value: 5, 20 | label: "5 people" 21 | }, 22 | { 23 | value: 6, 24 | label: "6 people" 25 | }, 26 | { 27 | value: 7, 28 | label: "7 people" 29 | }, 30 | { 31 | value: 8, 32 | label: "8 people" 33 | }, 34 | { 35 | value: 9, 36 | label: "9 people" 37 | }, 38 | { 39 | value: 10, 40 | label: "10 people" 41 | } 42 | ] -------------------------------------------------------------------------------- /data/times.ts: -------------------------------------------------------------------------------- 1 | export const times = [ 2 | { 3 | displayTime: "12:00 AM", 4 | time: "00:00:00.000Z", 5 | searchTimes: ["00:00:00.000Z", "00:30:00.000Z", "01:00:00.000Z"], 6 | }, 7 | { 8 | displayTime: "12:30 AM", 9 | time: "00:30:00.000Z", 10 | searchTimes: [ 11 | "00:00:00.000Z", 12 | "00:30:00.000Z", 13 | "01:00:00.000Z", 14 | "01:30:00.000Z", 15 | ], 16 | }, 17 | { 18 | displayTime: "1:00 AM", 19 | time: "01:00:00.000Z", 20 | searchTimes: [ 21 | "00:00:00.000Z", 22 | "00:30:00.000Z", 23 | "01:00:00.000Z", 24 | "01:30:00.000Z", 25 | "02:00:00.000Z", 26 | ], 27 | }, 28 | { 29 | displayTime: "1:30 AM", 30 | time: "01:30:00.000Z", 31 | searchTimes: [ 32 | "00:30:00.000Z", 33 | "01:00:00.000Z", 34 | "01:30:00.000Z", 35 | "02:00:00.000Z", 36 | "02:30:00.000Z", 37 | ], 38 | }, 39 | { 40 | displayTime: "2:00 AM", 41 | time: "02:00:00.000Z", 42 | searchTimes: [ 43 | "01:00:00.000Z", 44 | "01:30:00.000Z", 45 | "02:00:00.000Z", 46 | "02:30:00.000Z", 47 | "03:00:00.000Z", 48 | ], 49 | }, 50 | { 51 | displayTime: "2:30 AM", 52 | time: "02:30:00.000Z", 53 | searchTimes: [ 54 | "01:30:00.000Z", 55 | "02:00:00.000Z", 56 | "02:30:00.000Z", 57 | "03:00:00.000Z", 58 | "03:30:00.000Z", 59 | ], 60 | }, 61 | { 62 | displayTime: "3:00 AM", 63 | time: "03:00:00.000Z", 64 | searchTimes: [ 65 | "02:00:00.000Z", 66 | "02:30:00.000Z", 67 | "03:00:00.000Z", 68 | "03:30:00.000Z", 69 | "04:00:00.000Z", 70 | ], 71 | }, 72 | { 73 | displayTime: "3:30 AM", 74 | time: "03:30:00.000Z", 75 | searchTimes: [ 76 | "02:30:00.000Z", 77 | "03:00:00.000Z", 78 | "03:30:00.000Z", 79 | "04:00:00.000Z", 80 | "04:30:00.000Z", 81 | ], 82 | }, 83 | { 84 | displayTime: "4:00 AM", 85 | time: "04:00:00.000Z", 86 | searchTimes: [ 87 | "03:00:00.000Z", 88 | "03:30:00.000Z", 89 | "04:00:00.000Z", 90 | "04:30:00.000Z", 91 | "05:00:00.000Z", 92 | ], 93 | }, 94 | { 95 | displayTime: "4:30 AM", 96 | time: "04:30:00.000Z", 97 | searchTimes: [ 98 | "03:30:00.000Z", 99 | "04:00:00.000Z", 100 | "04:30:00.000Z", 101 | "05:00:00.000Z", 102 | "05:30:00.000Z", 103 | ], 104 | }, 105 | { 106 | displayTime: "5:00 AM", 107 | time: "05:00:00.000Z", 108 | searchTimes: [ 109 | "04:00:00.000Z", 110 | "04:30:00.000Z", 111 | "05:00:00.000Z", 112 | "05:30:00.000Z", 113 | "06:00:00.000Z", 114 | ], 115 | }, 116 | { 117 | displayTime: "5:30 AM", 118 | time: "05:30:00.000Z", 119 | searchTimes: [ 120 | "04:30:00.000Z", 121 | "05:00:00.000Z", 122 | "05:30:00.000Z", 123 | "06:00:00.000Z", 124 | "06:30:00.000Z", 125 | ], 126 | }, 127 | { 128 | displayTime: "6:00 AM", 129 | time: "06:00:00.000Z", 130 | searchTimes: [ 131 | "05:00:00.000Z", 132 | "05:30:00.000Z", 133 | "06:00:00.000Z", 134 | "06:30:00.000Z", 135 | "07:00:00.000Z", 136 | ], 137 | }, 138 | { 139 | displayTime: "6:30 AM", 140 | time: "06:30:00.000Z", 141 | searchTimes: [ 142 | "05:30:00.000Z", 143 | "06:00:00.000Z", 144 | "06:30:00.000Z", 145 | "07:00:00.000Z", 146 | "07:30:00.000Z", 147 | ], 148 | }, 149 | { 150 | displayTime: "7:00 AM", 151 | time: "07:00:00.000Z", 152 | searchTimes: [ 153 | "06:00:00.000Z", 154 | "06:30:00.000Z", 155 | "07:00:00.000Z", 156 | "07:30:00.000Z", 157 | "08:00:00.000Z", 158 | ], 159 | }, 160 | { 161 | displayTime: "7:30 AM", 162 | time: "07:30:00.000Z", 163 | searchTimes: [ 164 | "06:30:00.000Z", 165 | "07:00:00.000Z", 166 | "07:30:00.000Z", 167 | "08:00:00.000Z", 168 | "08:30:00.000Z", 169 | ], 170 | }, 171 | { 172 | displayTime: "8:00 AM", 173 | time: "08:00:00.000Z", 174 | searchTimes: [ 175 | "07:00:00.000Z", 176 | "07:30:00.000Z", 177 | "08:00:00.000Z", 178 | "08:30:00.000Z", 179 | "09:00:00.000Z", 180 | ], 181 | }, 182 | { 183 | displayTime: "8:30 AM", 184 | time: "08:30:00.000Z", 185 | searchTimes: [ 186 | "07:30:00.000Z", 187 | "08:00:00.000Z", 188 | "08:30:00.000Z", 189 | "09:00:00.000Z", 190 | "09:30:00.000Z", 191 | ], 192 | }, 193 | { 194 | displayTime: "9:00 AM", 195 | time: "09:00:00.000Z", 196 | searchTimes: [ 197 | "08:00:00.000Z", 198 | "08:30:00.000Z", 199 | "09:00:00.000Z", 200 | "09:30:00.000Z", 201 | "10:00:00.000Z", 202 | ], 203 | }, 204 | { 205 | displayTime: "9:30 AM", 206 | time: "09:30:00.000Z", 207 | searchTimes: [ 208 | "08:30:00.000Z", 209 | "09:00:00.000Z", 210 | "09:30:00.000Z", 211 | "10:00:00.000Z", 212 | "10:30:00.000Z", 213 | ], 214 | }, 215 | { 216 | displayTime: "10:00 AM", 217 | time: "10:00:00.000Z", 218 | searchTimes: [ 219 | "09:00:00.000Z", 220 | "09:30:00.000Z", 221 | "10:00:00.000Z", 222 | "10:30:00.000Z", 223 | "11:00:00.000Z", 224 | ], 225 | }, 226 | { 227 | displayTime: "10:30 AM", 228 | time: "10:30:00.000Z", 229 | searchTimes: [ 230 | "09:30:00.000Z", 231 | "10:00:00.000Z", 232 | "10:30:00.000Z", 233 | "11:00:00.000Z", 234 | "11:30:00.000Z", 235 | ], 236 | }, 237 | { 238 | displayTime: "11:00 AM", 239 | time: "11:00:00.000Z", 240 | searchTimes: [ 241 | "10:00:00.000Z", 242 | "10:30:00.000Z", 243 | "11:00:00.000Z", 244 | "11:30:00.000Z", 245 | "12:00:00.000Z", 246 | ], 247 | }, 248 | { 249 | displayTime: "11:30 AM", 250 | time: "11:30:00.000Z", 251 | searchTimes: [ 252 | "10:30:00.000Z", 253 | "11:00:00.000Z", 254 | "11:30:00.000Z", 255 | "12:00:00.000Z", 256 | "12:30:00.000Z", 257 | ], 258 | }, 259 | { 260 | displayTime: "12:00 PM", 261 | time: "12:00:00.000Z", 262 | searchTimes: [ 263 | "11:00:00.000Z", 264 | "11:30:00.000Z", 265 | "12:00:00.000Z", 266 | "12:30:00.000Z", 267 | "13:00:00.000Z", 268 | ], 269 | }, 270 | { 271 | displayTime: "12:30 PM", 272 | time: "12:30:00.000Z", 273 | searchTimes: [ 274 | "11:30:00.000Z", 275 | "12:00:00.000Z", 276 | "12:30:00.000Z", 277 | "13:00:00.000Z", 278 | "13:30:00.000Z", 279 | ], 280 | }, 281 | { 282 | displayTime: "1:00 PM", 283 | time: "13:00:00.000Z", 284 | searchTimes: [ 285 | "12:00:00.000Z", 286 | "12:30:00.000Z", 287 | "13:00:00.000Z", 288 | "13:30:00.000Z", 289 | "14:00:00.000Z", 290 | ], 291 | }, 292 | { 293 | displayTime: "1:30 PM", 294 | time: "13:30:00.000Z", 295 | searchTimes: [ 296 | "12:30:00.000Z", 297 | "13:00:00.000Z", 298 | "13:30:00.000Z", 299 | "14:00:00.000Z", 300 | "14:30:00.000Z", 301 | ], 302 | }, 303 | { 304 | displayTime: "2:00 PM", 305 | time: "14:00:00.000Z", 306 | searchTimes: [ 307 | "13:00:00.000Z", 308 | "13:30:00.000Z", 309 | "14:00:00.000Z", 310 | "14:30:00.000Z", 311 | "15:00:00.000Z", 312 | ], 313 | }, 314 | { 315 | displayTime: "2:30 PM", 316 | time: "14:30:00.000Z", 317 | searchTimes: [ 318 | "13:30:00.000Z", 319 | "14:00:00.000Z", 320 | "14:30:00.000Z", 321 | "15:00:00.000Z", 322 | "15:30:00.000Z", 323 | ], 324 | }, 325 | { 326 | displayTime: "3:00 PM", 327 | time: "15:00:00.000Z", 328 | searchTimes: [ 329 | "14:00:00.000Z", 330 | "14:30:00.000Z", 331 | "15:00:00.000Z", 332 | "15:30:00.000Z", 333 | "16:00:00.000Z", 334 | ], 335 | }, 336 | { 337 | displayTime: "3:30 PM", 338 | time: "15:30:00.000Z", 339 | searchTimes: [ 340 | "14:30:00.000Z", 341 | "15:00:00.000Z", 342 | "15:30:00.000Z", 343 | "16:00:00.000Z", 344 | "16:30:00.000Z", 345 | ], 346 | }, 347 | { 348 | displayTime: "4:00 PM", 349 | time: "16:00:00.000Z", 350 | searchTimes: [ 351 | "15:00:00.000Z", 352 | "15:30:00.000Z", 353 | "16:00:00.000Z", 354 | "16:30:00.000Z", 355 | "17:00:00.000Z", 356 | ], 357 | }, 358 | { 359 | displayTime: "4:30 PM", 360 | time: "16:30:00.000Z", 361 | searchTimes: [ 362 | "15:30:00.000Z", 363 | "16:00:00.000Z", 364 | "16:30:00.000Z", 365 | "17:00:00.000Z", 366 | "17:30:00.000Z", 367 | ], 368 | }, 369 | { 370 | displayTime: "5:00 PM", 371 | time: "17:00:00.000Z", 372 | searchTimes: [ 373 | "16:00:00.000Z", 374 | "16:30:00.000Z", 375 | "17:00:00.000Z", 376 | "17:30:00.000Z", 377 | "18:00:00.000Z", 378 | ], 379 | }, 380 | { 381 | displayTime: "5:30 PM", 382 | time: "17:30:00.000Z", 383 | searchTimes: [ 384 | "16:30:00.000Z", 385 | "17:00:00.000Z", 386 | "17:30:00.000Z", 387 | "18:00:00.000Z", 388 | "18:30:00.000Z", 389 | ], 390 | }, 391 | { 392 | displayTime: "6:00 PM", 393 | time: "18:00:00.000Z", 394 | searchTimes: [ 395 | "17:00:00.000Z", 396 | "17:30:00.000Z", 397 | "18:00:00.000Z", 398 | "18:30:00.000Z", 399 | "19:00:00.000Z", 400 | ], 401 | }, 402 | { 403 | displayTime: "6:30 PM", 404 | time: "18:30:00.000Z", 405 | searchTimes: [ 406 | "17:30:00.000Z", 407 | "18:00:00.000Z", 408 | "18:30:00.000Z", 409 | "19:00:00.000Z", 410 | "19:30:00.000Z", 411 | ], 412 | }, 413 | { 414 | displayTime: "7:00 PM", 415 | time: "19:00:00.000Z", 416 | searchTimes: [ 417 | "18:00:00.000Z", 418 | "18:30:00.000Z", 419 | "19:00:00.000Z", 420 | "19:30:00.000Z", 421 | "20:00:00.000Z", 422 | ], 423 | }, 424 | { 425 | displayTime: "7:30 PM", 426 | time: "19:30:00.000Z", 427 | searchTimes: [ 428 | "18:30:00.000Z", 429 | "19:00:00.000Z", 430 | "19:30:00.000Z", 431 | "20:00:00.000Z", 432 | "20:30:00.000Z", 433 | ], 434 | }, 435 | { 436 | displayTime: "8:00 PM", 437 | time: "20:00:00.000Z", 438 | searchTimes: [ 439 | "19:00:00.000Z", 440 | "19:30:00.000Z", 441 | "20:00:00.000Z", 442 | "20:30:00.000Z", 443 | "21:00:00.000Z", 444 | ], 445 | }, 446 | { 447 | displayTime: "8:30 PM", 448 | time: "20:30:00.000Z", 449 | searchTimes: [ 450 | "19:30:00.000Z", 451 | "20:00:00.000Z", 452 | "20:30:00.000Z", 453 | "21:00:00.000Z", 454 | "21:30:00.000Z", 455 | ], 456 | }, 457 | { 458 | displayTime: "9:00 PM", 459 | time: "21:00:00.000Z", 460 | searchTimes: [ 461 | "20:00:00.000Z", 462 | "20:30:00.000Z", 463 | "21:00:00.000Z", 464 | "21:30:00.000Z", 465 | "22:00:00.000Z", 466 | ], 467 | }, 468 | { 469 | displayTime: "9:30 PM", 470 | time: "21:30:00.000Z", 471 | searchTimes: [ 472 | "20:30:00.000Z", 473 | "21:00:00.000Z", 474 | "21:30:00.000Z", 475 | "22:00:00.000Z", 476 | "22:30:00.000Z", 477 | ], 478 | }, 479 | { 480 | displayTime: "10:00 PM", 481 | time: "22:00:00.000Z", 482 | searchTimes: [ 483 | "21:00:00.000Z", 484 | "21:30:00.000Z", 485 | "22:00:00.000Z", 486 | "22:30:00.000Z", 487 | "23:00:00.000Z", 488 | ], 489 | }, 490 | { 491 | displayTime: "10:30 PM", 492 | time: "22:30:00.000Z", 493 | searchTimes: [ 494 | "21:30:00.000Z", 495 | "22:00:00.000Z", 496 | "22:30:00.000Z", 497 | "23:00:00.000Z", 498 | "23:30:00.000Z", 499 | ], 500 | }, 501 | { 502 | displayTime: "11:00 PM", 503 | time: "23:00:00.000Z", 504 | searchTimes: [ 505 | "22:00:00.000Z", 506 | "22:30:00.000Z", 507 | "23:00:00.000Z", 508 | "23:30:00.000Z", 509 | ], 510 | }, 511 | { 512 | displayTime: "11:30 PM", 513 | time: "23:30:00.000Z", 514 | searchTimes: ["22:30:00.000Z", "23:00:00.000Z", "23:30:00.000Z"], 515 | }, 516 | ]; -------------------------------------------------------------------------------- /hooks/useAuth.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { getCookie, removeCookies } from "cookies-next"; 3 | import { useContext } from "react"; 4 | import { AuthenticationContext } from "../app/context/AuthContext"; 5 | 6 | const useAuth = () => { 7 | const { setAuthState } = useContext(AuthenticationContext); 8 | 9 | const signin = async ( 10 | { 11 | email, 12 | password, 13 | }: { 14 | email: string; 15 | password: string; 16 | }, 17 | handleClose: () => void 18 | ) => { 19 | setAuthState({ 20 | data: null, 21 | error: null, 22 | loading: true, 23 | }); 24 | try { 25 | const response = await axios.post( 26 | "http://localhost:3000/api/auth/signin", 27 | { 28 | email, 29 | password, 30 | } 31 | ); 32 | setAuthState({ 33 | data: response.data, 34 | error: null, 35 | loading: false, 36 | }); 37 | handleClose(); 38 | } catch (error: any) { 39 | setAuthState({ 40 | data: null, 41 | error: error.response.data.errorMessage, 42 | loading: false, 43 | }); 44 | } 45 | }; 46 | const signup = async ( 47 | { 48 | email, 49 | password, 50 | firstName, 51 | lastName, 52 | city, 53 | phone, 54 | }: { 55 | email: string; 56 | password: string; 57 | firstName: string; 58 | lastName: string; 59 | city: string; 60 | phone: string; 61 | }, 62 | handleClose: () => void 63 | ) => { 64 | setAuthState({ 65 | data: null, 66 | error: null, 67 | loading: true, 68 | }); 69 | try { 70 | const response = await axios.post( 71 | "http://localhost:3000/api/auth/signup", 72 | { 73 | email, 74 | password, 75 | firstName, 76 | lastName, 77 | city, 78 | phone, 79 | } 80 | ); 81 | setAuthState({ 82 | data: response.data, 83 | error: null, 84 | loading: false, 85 | }); 86 | handleClose(); 87 | } catch (error: any) { 88 | setAuthState({ 89 | data: null, 90 | error: error.response.data.errorMessage, 91 | loading: false, 92 | }); 93 | } 94 | }; 95 | 96 | const signout = () => { 97 | removeCookies("jwt"); 98 | 99 | setAuthState({ 100 | data: null, 101 | error: null, 102 | loading: false, 103 | }); 104 | }; 105 | 106 | return { 107 | signin, 108 | signup, 109 | signout, 110 | }; 111 | }; 112 | 113 | export default useAuth; 114 | -------------------------------------------------------------------------------- /hooks/useAvailabilities.ts: -------------------------------------------------------------------------------- 1 | import {useState} from "react" 2 | import axios from "axios" 3 | 4 | export default function useAvailabilities(){ 5 | const [loading, setLoading] = useState(false) 6 | const [error, setError] = useState(null) 7 | const [data, setData] = useState<{time: string; available: boolean}[] | null>(null) 8 | 9 | 10 | const fetchAvailabilities = async ({slug, partySize, day, time}: {slug: string; partySize: string; day: string; time: string}) => { 11 | setLoading(true) 12 | 13 | try { 14 | const response = await axios.get(`http://localhost:3000/api/restaurant/${slug}/availability`, { 15 | params: { 16 | day, 17 | time, 18 | partySize 19 | } 20 | }); 21 | console.log(response) 22 | setLoading(false) 23 | setData(response.data) 24 | } catch (error: any) { 25 | setLoading(false) 26 | setError(error.response.data.errorMessage) 27 | } 28 | 29 | } 30 | 31 | return {loading, data, error, fetchAvailabilities} 32 | 33 | } -------------------------------------------------------------------------------- /hooks/useReservation.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch, SetStateAction, useState } from "react"; 2 | import axios from "axios"; 3 | 4 | export default function useReservation() { 5 | const [loading, setLoading] = useState(false); 6 | const [error, setError] = useState(null); 7 | 8 | const createReservation = async ({ 9 | slug, 10 | partySize, 11 | day, 12 | time, 13 | bookerFirstName, 14 | bookerLastName, 15 | bookerPhone, 16 | bookerEmail, 17 | bookerOccasion, 18 | bookerRequest, 19 | setDidBook, 20 | }: { 21 | slug: string; 22 | partySize: string; 23 | day: string; 24 | time: string; 25 | bookerFirstName: string; 26 | bookerLastName: string; 27 | bookerPhone: string; 28 | bookerEmail: string; 29 | bookerOccasion: string; 30 | bookerRequest: string; 31 | setDidBook: Dispatch>; 32 | }) => { 33 | setLoading(true); 34 | 35 | try { 36 | const response = await axios.post( 37 | `http://localhost:3000/api/restaurant/${slug}/reserve`, 38 | { 39 | bookerFirstName, 40 | bookerLastName, 41 | bookerPhone, 42 | bookerEmail, 43 | bookerOccasion, 44 | bookerRequest, 45 | }, 46 | { 47 | params: { 48 | day, 49 | time, 50 | partySize, 51 | }, 52 | } 53 | ); 54 | 55 | setLoading(false); 56 | setDidBook(true); 57 | return response.data; 58 | } catch (error: any) { 59 | setLoading(false); 60 | setError(error.response.data.errorMessage); 61 | } 62 | }; 63 | 64 | return { loading, error, createReservation }; 65 | } 66 | -------------------------------------------------------------------------------- /html/homepage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {/* NAVBAR */} 4 | 17 | {/* NAVBAR */} 18 |
19 | {/* HEADER */} 20 |
21 |
22 |

23 | Find your table for any occasion 24 |

25 | {/* SEARCH BAR */} 26 |
27 | 32 | 35 |
36 | {/* SEARCH BAR */} 37 |
38 |
39 | {/* HEADER */} {/* CARDS */} 40 |
41 | {/* CARD */} 42 |
45 | 50 |
51 |

Milestones Grill

52 |
53 |
*****
54 |

77 reviews

55 |
56 |
57 |

Mexican

58 |

$$$$

59 |

Toronto

60 |
61 |

Booked 3 times today

62 |
63 |
64 | {/* CARD */} 65 |
66 | {/* CARDS */} 67 |
68 |
69 |
70 | -------------------------------------------------------------------------------- /html/reservationPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {/* NAVBAR */} 4 | 19 | {/* NAVBAR END */} 20 |
21 |
22 | {/* HEADER */} 23 |
24 |

You're almost done!

25 |
26 | 31 |
32 |

33 | Aiāna Restaurant Collective 34 |

35 |
36 |

Tues, 22, 2023

37 |

7:30 PM

38 |

3 people

39 |
40 |
41 |
42 |
43 | {/* HEADER */} {/* FORM */} 44 |
45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 |

81 | By clicking “Complete reservation” you agree to the OpenTable Terms 82 | of Use and Privacy Policy. Standard text message rates may apply. 83 | You may opt out of receiving text messages at any time. 84 |

85 |
86 |
87 |
88 |
89 |
90 | -------------------------------------------------------------------------------- /html/restaurantDetailsPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {/* NAVBAR */} 4 | 19 | {/* NAVBAR */} {/* HEADER */} 20 |
21 |
24 |

25 | Milestones Grill (Toronto) 26 |

27 |
28 |
29 | {/* HEADER */} {/* DESCRIPTION PORTION */} 30 |
31 |
32 | {/* RESAURANT NAVBAR */} 33 | 37 | {/* RESAURANT NAVBAR */} {/* TITLE */} 38 |
39 |

Milesstone Grill

40 |
41 | {/* TITLE */} {/* RATING */} 42 |
43 |
44 |

*****

45 |

4.9

46 |
47 |
48 |

600 Reviews

49 |
50 |
51 | {/* RATING */} {/* DESCRIPTION */} 52 |
53 |

54 | The classics you love prepared with a perfect twist, all served up 55 | in an atmosphere that feels just right. That’s the Milestones 56 | promise. So, whether you’re celebrating a milestone, making the most 57 | of Happy Hour or enjoying brunch with friends, you can be sure that 58 | every Milestones experience is a simple and perfectly memorable one. 59 |

60 |
61 | {/* DESCRIPTION */} {/* IMAGES */} 62 |
63 |

64 | 5 photos 65 |

66 |
67 | 72 | 77 | 82 | 87 | 92 |
93 |
94 | {/* IMAGES */} {/* REVIEWS */} 95 |
96 |

97 | What 100 people are saying 98 |

99 |
100 | {/* REVIEW CARD */} 101 |
102 |
103 |
104 |
107 |

MJ

108 |
109 |

Micheal Jordan

110 |
111 |
112 |
113 |
*****
114 |
115 |
116 |

117 | Laurie was on top of everything! Slow night due to the 118 | snow storm so it worked in our favor to have more one on 119 | one with the staff. Delicious and well worth the money. 120 |

121 |
122 |
123 |
124 |
125 | {/* REVIEW CARD */} 126 |
127 |
128 | {/* REVIEWS */} 129 |
130 |
131 |
132 |
133 |

Make a Reservation

134 |
135 |
136 | 137 | 141 |
142 |
143 |
144 | 145 | 146 |
147 |
148 | 149 | 153 |
154 |
155 |
156 | 161 |
162 |
163 |
164 |
165 | {/* DESCRIPTION PORTION */} {/* RESERVATION CARD PORTION */} {/* RESERVATION 166 | CARD PORTION */} 167 |
168 |
169 | -------------------------------------------------------------------------------- /html/restaurantMenuPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {/* NAVBAR */} 4 | 19 | {/* NAVBAR */} {/* HEADER */} 20 |
21 |
24 |

25 | Milestones Grill (Toronto) 26 |

27 |
28 |
29 | {/* HEADER */} {/* DESCRIPTION PORTION */} 30 |
31 |
32 | {/* RESAURANT NAVBAR */} 33 | 37 | {/* RESAURANT NAVBAR */} {/* MENU */} 38 |
39 |
40 |
41 |

Menu

42 |
43 |
44 | {/* MENU CARD */} 45 |
46 |

Surf and Turf

47 |

48 | A well done steak with lobster and rice 49 |

50 |

$80.00

51 |
52 | {/* MENU CARD */} 53 |
54 |
55 |
56 | {/* MENU */} 57 |
58 |
59 | {/* DESCRIPTION PORTION */} 60 |
61 |
62 | -------------------------------------------------------------------------------- /html/searchPage.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | {/* NAVBAR */} 4 | 19 | {/* HEADER */} 20 |
21 |
22 | 27 | 30 |
31 |
32 |
33 | {/* SEARCH SIDE BAR */} 34 |
35 |
36 |

Region

37 |

Toronto

38 |

Ottawa

39 |

Montreal

40 |

Hamilton

41 |

Kingston

42 |

Niagara

43 |
44 |
45 |

Cuisine

46 |

Mexican

47 |

Italian

48 |

Chinese

49 |
50 |
51 |

Price

52 |
53 | 56 | 61 | 66 |
67 |
68 |
69 | {/* SEARCH SIDE BAR */} 70 |
71 | {/* RESAURANT CAR */} 72 |
73 | 78 |
79 |

Aiāna Restaurant Collective

80 |
81 |
*****
82 |

Awesome

83 |
84 |
85 |
86 |

$$$

87 |

Mexican

88 |

Ottawa

89 |
90 |
91 | 94 |
95 |
96 | {/* RESAURANT CAR */} 97 |
98 |
99 |
100 |
101 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextRequest, NextResponse } from "next/server"; 2 | import * as jose from "jose"; 3 | export async function middleware(req: NextRequest, res: NextResponse) { 4 | const bearerToken = req.headers.get("authorization") as string; 5 | 6 | if (!bearerToken) { 7 | return new NextResponse( 8 | JSON.stringify({ errorMessage: "Unauthorized request" }), 9 | { status: 401 } 10 | ); 11 | } 12 | 13 | const token = bearerToken.split(" ")[1]; 14 | 15 | if (!token) { 16 | return new NextResponse( 17 | JSON.stringify({ errorMessage: "Unauthorized request" }), 18 | { status: 401 } 19 | ); 20 | } 21 | 22 | const secret = new TextEncoder().encode(process.env.JWT_SECRET); 23 | 24 | try { 25 | await jose.jwtVerify(token, secret); 26 | } catch (error) { 27 | return new NextResponse( 28 | JSON.stringify({ errorMessage: "Unauthorized request" }), 29 | { status: 401 } 30 | ); 31 | } 32 | } 33 | 34 | export const config = { 35 | matcher: ["/api/auth/me"], 36 | }; 37 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | experimental: { 4 | appDir: true, 5 | }, 6 | } 7 | 8 | module.exports = nextConfig 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "opentablenextjs", 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 | "@emotion/react": "^11.10.5", 13 | "@emotion/styled": "^11.10.5", 14 | "@mui/material": "^5.11.6", 15 | "@next/font": "13.1.4", 16 | "@prisma/client": "^4.8.1", 17 | "@types/bcrypt": "^5.0.0", 18 | "@types/jsonwebtoken": "^9.0.1", 19 | "@types/node": "18.11.18", 20 | "@types/react": "18.0.27", 21 | "@types/react-dom": "18.0.10", 22 | "@types/validator": "^13.7.11", 23 | "axios": "^1.3.0", 24 | "bcrypt": "^5.1.0", 25 | "cookies-next": "^2.1.1", 26 | "date-fns": "^2.29.3", 27 | "jose": "^4.11.2", 28 | "jsonwebtoken": "^9.0.0", 29 | "next": "13.1.4", 30 | "prisma": "^4.8.1", 31 | "react": "18.2.0", 32 | "react-datepicker": "^4.10.0", 33 | "react-dom": "18.2.0", 34 | "typescript": "4.9.4", 35 | "validator": "^13.7.0" 36 | }, 37 | "devDependencies": { 38 | "@types/react-datepicker": "^4.8.0", 39 | "autoprefixer": "^10.4.13", 40 | "postcss": "^8.4.20", 41 | "tailwindcss": "^3.2.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pages/api/auth/me.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | import jwt from "jsonwebtoken"; 4 | import { PrismaClient } from "@prisma/client"; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | const bearerToken = req.headers["authorization"] as string; 13 | const token = bearerToken.split(" ")[1]; 14 | 15 | const payload = jwt.decode(token) as { email: string }; 16 | 17 | if (!payload.email) { 18 | return res.status(401).json({ 19 | errorMessage: "Unauthorized request", 20 | }); 21 | } 22 | 23 | const user = await prisma.user.findUnique({ 24 | where: { 25 | email: payload.email, 26 | }, 27 | select: { 28 | id: true, 29 | first_name: true, 30 | last_name: true, 31 | email: true, 32 | city: true, 33 | phone: true, 34 | }, 35 | }); 36 | 37 | if (!user) { 38 | return res.status(401).json({ 39 | errorMessage: "User not found", 40 | }); 41 | } 42 | 43 | return res.json({ 44 | id: user.id, 45 | firstName: user.first_name, 46 | lastName: user.last_name, 47 | phone: user.phone, 48 | city: user.city, 49 | }); 50 | } 51 | -------------------------------------------------------------------------------- /pages/api/auth/signin.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import validator from "validator"; 4 | import bcrypt from "bcrypt"; 5 | import * as jose from "jose"; 6 | import { setCookie } from "cookies-next"; 7 | 8 | const prisma = new PrismaClient(); 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | if (req.method === "POST") { 15 | const errors: string[] = []; 16 | const { email, password } = req.body; 17 | 18 | const validationSchema = [ 19 | { 20 | valid: validator.isEmail(email), 21 | errorMessage: "Email is invalid", 22 | }, 23 | { 24 | valid: validator.isLength(password, { 25 | min: 1, 26 | }), 27 | errorMessage: "Password is invalid", 28 | }, 29 | ]; 30 | 31 | validationSchema.forEach((check) => { 32 | if (!check.valid) { 33 | errors.push(check.errorMessage); 34 | } 35 | }); 36 | 37 | if (errors.length) { 38 | return res.status(400).json({ errorMessage: errors[0] }); 39 | } 40 | 41 | const user = await prisma.user.findUnique({ 42 | where: { 43 | email, 44 | }, 45 | }); 46 | 47 | if (!user) { 48 | return res 49 | .status(401) 50 | .json({ errorMessage: "Email or password is invalid" }); 51 | } 52 | 53 | const isMatch = await bcrypt.compare(password, user.password); 54 | 55 | if (!isMatch) { 56 | return res 57 | .status(401) 58 | .json({ errorMessage: "Email or password is invalid" }); 59 | } 60 | 61 | const alg = "HS256"; 62 | 63 | const secret = new TextEncoder().encode(process.env.JWT_SECRET); 64 | 65 | const token = await new jose.SignJWT({ email: user.email }) 66 | .setProtectedHeader({ alg }) 67 | .setExpirationTime("24h") 68 | .sign(secret); 69 | 70 | setCookie("jwt", token, { req, res, maxAge: 60 * 6 * 24 }); 71 | 72 | return res.status(200).json({ 73 | firstName: user.first_name, 74 | lastName: user.last_name, 75 | email: user.email, 76 | phone: user.phone, 77 | city: user.city, 78 | }); 79 | } 80 | 81 | return res.status(404).json("Unknown endpoint"); 82 | } 83 | -------------------------------------------------------------------------------- /pages/api/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import validator from "validator"; 3 | import { PrismaClient } from "@prisma/client"; 4 | import bcrypt from "bcrypt"; 5 | import * as jose from "jose"; 6 | import { setCookie } from "cookies-next"; 7 | 8 | const prisma = new PrismaClient(); 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | if (req.method === "POST") { 15 | const { firstName, lastName, email, phone, city, password } = req.body; 16 | const errors: string[] = []; 17 | 18 | const validationSchema = [ 19 | { 20 | valid: validator.isLength(firstName, { 21 | min: 1, 22 | max: 20, 23 | }), 24 | errorMessage: "First name is invalid", 25 | }, 26 | { 27 | valid: validator.isLength(lastName, { 28 | min: 1, 29 | max: 20, 30 | }), 31 | errorMessage: "First name is invalid", 32 | }, 33 | { 34 | valid: validator.isEmail(email), 35 | errorMessage: "Email is invalid", 36 | }, 37 | { 38 | valid: validator.isMobilePhone(phone), 39 | errorMessage: "Phone number is invalid", 40 | }, 41 | { 42 | valid: validator.isLength(city, { min: 1 }), 43 | errorMessage: "City is invalid", 44 | }, 45 | { 46 | valid: validator.isStrongPassword(password), 47 | errorMessage: "Password is not strong enough", 48 | }, 49 | ]; 50 | 51 | validationSchema.forEach((check) => { 52 | if (!check.valid) { 53 | errors.push(check.errorMessage); 54 | } 55 | }); 56 | 57 | if (errors.length) { 58 | return res.status(400).json({ errorMessage: errors[0] }); 59 | } 60 | 61 | const userWithEmail = await prisma.user.findUnique({ 62 | where: { 63 | email, 64 | }, 65 | }); 66 | 67 | if (userWithEmail) { 68 | return res 69 | .status(400) 70 | .json({ errorMessage: "Email is associated with another account" }); 71 | } 72 | 73 | const hashedPassword = await bcrypt.hash(password, 10); 74 | 75 | const user = await prisma.user.create({ 76 | data: { 77 | first_name: firstName, 78 | last_name: lastName, 79 | password: hashedPassword, 80 | city, 81 | phone, 82 | email, 83 | }, 84 | }); 85 | 86 | const alg = "HS256"; 87 | 88 | const secret = new TextEncoder().encode(process.env.JWT_SECRET); 89 | 90 | const token = await new jose.SignJWT({ email: user.email }) 91 | .setProtectedHeader({ alg }) 92 | .setExpirationTime("24h") 93 | .sign(secret); 94 | 95 | setCookie("jwt", token, { req, res, maxAge: 60 * 6 * 24 }); 96 | 97 | return res.status(200).json({ 98 | firstName: user.first_name, 99 | lastName: user.last_name, 100 | email: user.email, 101 | phone: user.phone, 102 | city: user.city, 103 | }); 104 | } 105 | 106 | return res.status(404).json("Unknown endpoint"); 107 | } 108 | -------------------------------------------------------------------------------- /pages/api/hello.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from 'next' 3 | 4 | type Data = { 5 | name: string 6 | } 7 | 8 | export default function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | res.status(200).json({ name: 'John Doe' }) 13 | } 14 | -------------------------------------------------------------------------------- /pages/api/restaurant/[slug]/availability.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { times } from "../../../../data"; 4 | import { findAvailabileTables } from "../../../../services/restaurant/findAvailableTables"; 5 | 6 | const prisma = new PrismaClient(); 7 | 8 | export default async function handler( 9 | req: NextApiRequest, 10 | res: NextApiResponse 11 | ) { 12 | if (req.method === "GET") { 13 | const { slug, day, time, partySize } = req.query as { 14 | slug: string; 15 | day: string; 16 | time: string; 17 | partySize: string; 18 | }; 19 | 20 | if (!day || !time || !partySize) { 21 | return res.status(400).json({ 22 | errorMessage: "Invalid data provided", 23 | }); 24 | } 25 | 26 | const restaurant = await prisma.restaurant.findUnique({ 27 | where: { 28 | slug, 29 | }, 30 | select: { 31 | tables: true, 32 | open_time: true, 33 | close_time: true, 34 | }, 35 | }); 36 | 37 | if (!restaurant) { 38 | return res.status(400).json({ 39 | errorMessage: "Invalid data provided", 40 | }); 41 | } 42 | 43 | const searchTimesWithTables = await findAvailabileTables({ 44 | day, 45 | time, 46 | res, 47 | restaurant, 48 | }); 49 | 50 | if (!searchTimesWithTables) { 51 | return res.status(400).json({ 52 | errorMessage: "Invalid data provided", 53 | }); 54 | } 55 | 56 | const availabilities = searchTimesWithTables 57 | .map((t) => { 58 | const sumSeats = t.tables.reduce((sum, table) => { 59 | return sum + table.seats; 60 | }, 0); 61 | 62 | return { 63 | time: t.time, 64 | available: sumSeats >= parseInt(partySize), 65 | }; 66 | }) 67 | .filter((availability) => { 68 | const timeIsAfterOpeningHour = 69 | new Date(`${day}T${availability.time}`) >= 70 | new Date(`${day}T${restaurant.open_time}`); 71 | const timeIsBeforeClosingHour = 72 | new Date(`${day}T${availability.time}`) <= 73 | new Date(`${day}T${restaurant.close_time}`); 74 | 75 | return timeIsAfterOpeningHour && timeIsBeforeClosingHour; 76 | }); 77 | 78 | return res.json(availabilities); 79 | } 80 | } 81 | 82 | // http://localhost:3000/api/restaurant/vivaan-fine-indian-cuisine-ottawa/availability?day=2023-02-03&time=15:00:00.000Z&partySize=8 83 | -------------------------------------------------------------------------------- /pages/api/restaurant/[slug]/reserve.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | import { findAvailabileTables } from "../../../../services/restaurant/findAvailableTables"; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | export default async function handler( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | if (req.method === "POST") { 12 | const { slug, day, time, partySize } = req.query as { 13 | slug: string; 14 | day: string; 15 | time: string; 16 | partySize: string; 17 | }; 18 | 19 | const { 20 | bookerEmail, 21 | bookerPhone, 22 | bookerFirstName, 23 | bookerLastName, 24 | bookerOccasion, 25 | bookerRequest, 26 | } = req.body; 27 | 28 | const restaurant = await prisma.restaurant.findUnique({ 29 | where: { 30 | slug, 31 | }, 32 | select: { 33 | tables: true, 34 | open_time: true, 35 | close_time: true, 36 | id: true, 37 | }, 38 | }); 39 | 40 | if (!restaurant) { 41 | return res.status(400).json({ 42 | errorMessage: "Restaurant not found", 43 | }); 44 | } 45 | 46 | if ( 47 | new Date(`${day}T${time}`) < new Date(`${day}T${restaurant.open_time}`) || 48 | new Date(`${day}T${time}`) > new Date(`${day}T${restaurant.close_time}`) 49 | ) { 50 | return res.status(400).json({ 51 | errorMessage: "Restaurant is not open at that time", 52 | }); 53 | } 54 | 55 | const searchTimesWithTables = await findAvailabileTables({ 56 | day, 57 | time, 58 | res, 59 | restaurant, 60 | }); 61 | 62 | if (!searchTimesWithTables) { 63 | return res.status(400).json({ 64 | errorMessage: "Invalid data provided", 65 | }); 66 | } 67 | 68 | const searchTimeWithTables = searchTimesWithTables.find((t) => { 69 | return t.date.toISOString() === new Date(`${day}T${time}`).toISOString(); 70 | }); 71 | 72 | if (!searchTimeWithTables) { 73 | return res.status(400).json({ 74 | errorMessage: "No availablity, cannot book", 75 | }); 76 | } 77 | 78 | const tablesCount: { 79 | 2: number[]; 80 | 4: number[]; 81 | } = { 82 | 2: [], 83 | 4: [], 84 | }; 85 | 86 | searchTimeWithTables.tables.forEach((table) => { 87 | if (table.seats === 2) { 88 | tablesCount[2].push(table.id); 89 | } else { 90 | tablesCount[4].push(table.id); 91 | } 92 | }); 93 | 94 | const tablesToBooks: number[] = []; 95 | let seatsRemaining = parseInt(partySize); 96 | 97 | while (seatsRemaining > 0) { 98 | if (seatsRemaining >= 3) { 99 | if (tablesCount[4].length) { 100 | tablesToBooks.push(tablesCount[4][0]); 101 | tablesCount[4].shift(); 102 | seatsRemaining = seatsRemaining - 4; 103 | } else { 104 | tablesToBooks.push(tablesCount[2][0]); 105 | tablesCount[2].shift(); 106 | seatsRemaining = seatsRemaining - 2; 107 | } 108 | } else { 109 | if (tablesCount[2].length) { 110 | tablesToBooks.push(tablesCount[2][0]); 111 | tablesCount[2].shift(); 112 | seatsRemaining = seatsRemaining - 2; 113 | } else { 114 | tablesToBooks.push(tablesCount[4][0]); 115 | tablesCount[4].shift(); 116 | seatsRemaining = seatsRemaining - 4; 117 | } 118 | } 119 | } 120 | 121 | const booking = await prisma.booking.create({ 122 | data: { 123 | number_of_people: parseInt(partySize), 124 | booking_time: new Date(`${day}T${time}`), 125 | booker_email: bookerEmail, 126 | booker_phone: bookerPhone, 127 | booker_first_name: bookerFirstName, 128 | booker_last_name: bookerLastName, 129 | booker_occasion: bookerOccasion, 130 | booker_request: bookerRequest, 131 | restaurant_id: restaurant.id, 132 | }, 133 | }); 134 | 135 | const bookingsOnTablesData = tablesToBooks.map((table_id) => { 136 | return { 137 | table_id, 138 | booking_id: booking.id, 139 | }; 140 | }); 141 | 142 | await prisma.bookingsOnTables.createMany({ 143 | data: bookingsOnTablesData, 144 | }); 145 | 146 | return res.json(booking); 147 | } 148 | } 149 | 150 | // http://localhost:3000/api/restaurant/vivaan-fine-indian-cuisine-ottawa/reserve?day=2023-02-03&time=15:00:00.000Z&partySize=8 151 | -------------------------------------------------------------------------------- /pages/api/seed.ts: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | import type { NextApiRequest, NextApiResponse } from "next"; 3 | import { PRICE, PrismaClient } from "@prisma/client"; 4 | 5 | const prisma = new PrismaClient(); 6 | type Data = { 7 | name: string; 8 | }; 9 | 10 | export default async function handler( 11 | req: NextApiRequest, 12 | res: NextApiResponse 13 | ) { 14 | await prisma.table.deleteMany(); 15 | await prisma.review.deleteMany(); 16 | await prisma.item.deleteMany(); 17 | await prisma.restaurant.deleteMany(); 18 | await prisma.location.deleteMany(); 19 | await prisma.cuisine.deleteMany(); 20 | await prisma.user.deleteMany(); 21 | 22 | await prisma.location.createMany({ 23 | data: [{ name: "ottawa" }, { name: "toronto" }, { name: "niagara" }], 24 | }); 25 | 26 | await prisma.cuisine.createMany({ 27 | data: [{ name: "indian" }, { name: "italian" }, { name: "mexican" }], 28 | }); 29 | 30 | const locations = await prisma.location.findMany(); 31 | const cuisines = await prisma.cuisine.findMany(); 32 | 33 | const indianCuisineId = 34 | cuisines.find((cuisine) => cuisine.name === "indian")?.id || 1; 35 | const mexicanCuisineId = 36 | cuisines.find((cuisine) => cuisine.name === "mexican")?.id || 1; 37 | const italianCuisineId = 38 | cuisines.find((cuisine) => cuisine.name === "italian")?.id || 1; 39 | 40 | const ottawaLocationId = 41 | locations.find((location) => location.name === "ottawa")?.id || 1; 42 | const torontoLocationId = 43 | locations.find((location) => location.name === "toronto")?.id || 1; 44 | const niagaraLocationId = 45 | locations.find((location) => location.name === "niagara")?.id || 1; 46 | 47 | await prisma.restaurant.createMany({ 48 | data: [ 49 | // INDIAN // 50 | { 51 | name: "Vivaan - fine Indian", 52 | main_image: 53 | "https://resizer.otstatic.com/v2/photos/wide-huge/1/32109459.jpg", 54 | price: PRICE.REGULAR, 55 | description: 56 | "Vivaan is Modern Indian Cuisine serving dishes from different regions of India. We carefully select our ingredients and use them to make authentic Indian recipes and our chef puts his modern flair and twists to the dishes.", 57 | images: [ 58 | "https://resizer.otstatic.com/v2/photos/xlarge/2/32109461.jpg", 59 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32459786.jpg", 60 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32484701.jpg", 61 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32484708.jpg", 62 | ], 63 | open_time: "14:30:00.000Z", 64 | close_time: "21:30:00.000Z", 65 | slug: "vivaan-fine-indian-cuisine-ottawa", 66 | location_id: ottawaLocationId, 67 | cuisine_id: indianCuisineId, 68 | }, 69 | { 70 | name: "RamaKrishna Indian", 71 | main_image: 72 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/47417441.jpg", 73 | price: PRICE.CHEAP, 74 | description: 75 | "With 20 years of experience cooking in the finest restaurants, our chef is excited to present their vision to you and all our guests. Our caring and committed staff will ensure you have a fantastic experience with us.", 76 | images: [ 77 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47417455.png", 78 | "https://resizer.otstatic.com/v2/photos/xlarge/1/47417456.jpg", 79 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47417457.jpg", 80 | "https://resizer.otstatic.com/v2/photos/xlarge/1/47417458.jpg", 81 | ], 82 | open_time: "12:30:00.000Z", 83 | close_time: "22:00:00.000Z", 84 | slug: "ramakrishna-indian-restaurant-ottawa", 85 | location_id: ottawaLocationId, 86 | cuisine_id: indianCuisineId, 87 | }, 88 | { 89 | name: "Coconut Lagoon", 90 | main_image: 91 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/48545745.jpg", 92 | price: PRICE.EXPENSIVE, 93 | description: 94 | "At Coconut Lagoon prepare yourselves for a most memorable journey through South Indian cuisine and feast on high quality food of inimitable flavour, aroma and originality in the vibrant setting of Coconut Lagoon.", 95 | images: [ 96 | "https://resizer.otstatic.com/v2/photos/xlarge/1/30045353.jpg", 97 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48545766.jpg", 98 | "https://resizer.otstatic.com/v2/photos/xlarge/1/30045356.jpg", 99 | "https://resizer.otstatic.com/v2/photos/xlarge/1/49399187.jpg", 100 | ], 101 | open_time: "17:30:00.000Z", 102 | close_time: "22:00:00.000Z", 103 | slug: "coconut-lagoon-ottawa", 104 | location_id: ottawaLocationId, 105 | cuisine_id: indianCuisineId, 106 | }, 107 | { 108 | name: "Last Train to Delhi", 109 | main_image: 110 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/26429498.jpg", 111 | price: PRICE.REGULAR, 112 | description: 113 | "Welcome to Last Train to Delhi. We are a progressive Indian restaurant in the beautiful Glebe community in Ottawa. Our speciality is Northern Indian food, classics like Murg Mahkini and some modern dishes like Crispy Shrimp. We are a small cozy restaurant, so make sure that you reserve through OpenTable.", 114 | images: [ 115 | "https://resizer.otstatic.com/v2/photos/xlarge/1/29477326.jpg", 116 | "https://resizer.otstatic.com/v2/photos/xlarge/1/29777084.jpg", 117 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32104059.jpg", 118 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32104066.jpg", 119 | ], 120 | open_time: "10:00:00.000Z", 121 | close_time: "21:00:00.000Z", 122 | slug: "last-train-to-delhi-ottawa", 123 | location_id: ottawaLocationId, 124 | cuisine_id: indianCuisineId, 125 | }, 126 | { 127 | name: "Adrak Yorkville", 128 | main_image: 129 | "https://resizer.otstatic.com/v2/photos/wide-huge/4/47914200.jpg", 130 | price: PRICE.EXPENSIVE, 131 | description: 132 | "Namaste and welcome to Adrak - a place where food unites all. We take you through a journey of the past and present, as we hope to encourage thought-provoking conversations amid elevated Indian food.", 133 | images: [ 134 | "https://resizer.otstatic.com/v2/photos/xlarge/3/47914185.jpg", 135 | "https://resizer.otstatic.com/v2/photos/xlarge/3/47914186.jpg", 136 | "https://resizer.otstatic.com/v2/photos/xlarge/1/47980632.jpg", 137 | "https://resizer.otstatic.com/v2/photos/xlarge/1/47980634.jpg", 138 | ], 139 | open_time: "16:00:00.000Z", 140 | close_time: "21:00:00.000Z", 141 | slug: "adrak-yorkville-toronto", 142 | location_id: torontoLocationId, 143 | cuisine_id: indianCuisineId, 144 | }, 145 | { 146 | name: "Curryish Tavern", 147 | main_image: 148 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/49294128.jpg", 149 | price: PRICE.REGULAR, 150 | description: 151 | "The most unique Indian food in the world! We are inspired by the seasons of Ontario and the cooking techniques of the world. Regale in the imagination of Chef Miheer Shete's dishes and change your palate for life.", 152 | images: [ 153 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48765139.jpg", 154 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48765149.jpg", 155 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48765157.jpg", 156 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48765162.jpg", 157 | ], 158 | open_time: "10:00:00.000Z", 159 | close_time: "21:00:00.000Z", 160 | slug: "curryish-tavern-toronto", 161 | location_id: torontoLocationId, 162 | cuisine_id: indianCuisineId, 163 | }, 164 | { 165 | name: "Utsav", 166 | main_image: 167 | "https://resizer.otstatic.com/v2/photos/xlarge/1/26646742.jpg", 168 | price: PRICE.CHEAP, 169 | description: 170 | "Utsav is an ancient Sanskrit word meaning festival. An integral part of Indian culture, Indian festivals are innumerable and equally varied in origin from the Himalayan foothills to the Peninsula's tip and food plays a very prominent part of the festive events.", 171 | images: [ 172 | "https://resizer.otstatic.com/v2/photos/xlarge/1/26646742.jpg", 173 | "https://resizer.otstatic.com/v2/photos/xlarge/1/26646761.jpg", 174 | ], 175 | open_time: "14:00:00.000Z", 176 | close_time: "19:00:00.000Z", 177 | slug: "utsav-toronto", 178 | location_id: torontoLocationId, 179 | cuisine_id: indianCuisineId, 180 | }, 181 | { 182 | name: "Pukka", 183 | main_image: 184 | "https://resizer.otstatic.com/v2/photos/wide-huge/1/25733300.jpg", 185 | price: PRICE.EXPENSIVE, 186 | description: 187 | "At this refined, yet casual, Indian restaurant, the portions are large, the wine list is top-notch, and the ambience encourages sharing.", 188 | images: [ 189 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25733294.jpg", 190 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25733295.jpg", 191 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25733296.jpg", 192 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25733297.jpg", 193 | ], 194 | open_time: "12:00:00.000Z", 195 | close_time: "21:00:00.000Z", 196 | slug: "pukka-niagara", 197 | location_id: niagaraLocationId, 198 | cuisine_id: indianCuisineId, 199 | }, 200 | { 201 | name: "Kamasutra Indian", 202 | main_image: 203 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25602522.jpg", 204 | price: PRICE.CHEAP, 205 | description: 206 | "This elegant fine dining Indian Restaurant has been satisfying the Indian tandoori and curry cravings for 12 years in Toronto.", 207 | images: [ 208 | "https://resizer.otstatic.com/v2/photos/xlarge/3/31854185.jpg", 209 | "https://resizer.otstatic.com/v2/photos/xlarge/3/31854188.jpg", 210 | "https://resizer.otstatic.com/v2/photos/xlarge/25684161.jpg", 211 | "https://resizer.otstatic.com/v2/photos/xlarge/26009011.jpg", 212 | ], 213 | open_time: "10:00:00.000Z", 214 | close_time: "21:00:00.000Z", 215 | slug: "kamasutra-indian-restaurant-and-wine-bar-niagara", 216 | location_id: niagaraLocationId, 217 | cuisine_id: indianCuisineId, 218 | }, 219 | // MEXICAN // 220 | { 221 | name: "Eldorado Taco", 222 | main_image: 223 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/42557297.jpg", 224 | price: PRICE.REGULAR, 225 | description: 226 | "Eldorado Taco restaurant is excited to serve you traditional Mexican cuisine, re-imagined with a distinct modern flair, in a stylish setting on Preston street. Striving to bring you some of Ottawa’s best Tacos, margaritas and Tequila. Reserve your table now!", 227 | images: [ 228 | "https://resizer.otstatic.com/v2/photos/xlarge/1/29501707.jpg", 229 | "https://resizer.otstatic.com/v2/photos/xlarge/1/29501713.jpg", 230 | "https://resizer.otstatic.com/v2/photos/xlarge/3/29501715.jpg", 231 | "https://resizer.otstatic.com/v2/photos/xlarge/1/42557295.jpg", 232 | ], 233 | open_time: "16:00:00.000Z", 234 | close_time: "19:00:00.000Z", 235 | slug: "eldorado-taco-ottawa", 236 | location_id: ottawaLocationId, 237 | cuisine_id: mexicanCuisineId, 238 | }, 239 | { 240 | name: "La Bartola", 241 | main_image: 242 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/48981502.jpg", 243 | price: PRICE.EXPENSIVE, 244 | description: 245 | "At La Bartola, we inspire a passion for authentic Mexican flavours. We use simple, fresh, and high-quality local & Mexican ingredients to craft delicious and thoughtful food.", 246 | images: [ 247 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981480.jpg", 248 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981483.jpg", 249 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981485.jpg", 250 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981487.jpg", 251 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981490.jpg", 252 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48981492.jpg", 253 | ], 254 | open_time: "12:00:00.000Z", 255 | close_time: "21:00:00.000Z", 256 | slug: "la-bartola-ottawa", 257 | location_id: ottawaLocationId, 258 | cuisine_id: mexicanCuisineId, 259 | }, 260 | { 261 | name: "El Catrin", 262 | main_image: 263 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/28028883.png", 264 | price: PRICE.CHEAP, 265 | description: 266 | "Reservations are booked for indoors only. Seating time will be limited to two hours maximum.", 267 | images: [ 268 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25770621.jpg", 269 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25770622.jpg", 270 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25770624.jpg", 271 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25770625.jpg", 272 | ], 273 | open_time: "09:00:00.000Z", 274 | close_time: "21:00:00.000Z", 275 | slug: "el-catrin-ottawa", 276 | location_id: ottawaLocationId, 277 | cuisine_id: mexicanCuisineId, 278 | }, 279 | { 280 | name: "3 Mariachis", 281 | main_image: 282 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/32449465.jpg", 283 | price: PRICE.CHEAP, 284 | description: 285 | "Specializing in the preparation of high quality Mexican food. Our vibrant décor, carefully selected menu, great staff and exciting entertainment will ensure that you are treated to a unique dining experience.", 286 | images: [ 287 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32490939.jpg", 288 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32490987.jpg", 289 | "https://resizer.otstatic.com/v2/photos/xlarge/1/32507838.jpg", 290 | "https://resizer.otstatic.com/v2/photos/xlarge/1/41724689.jpg", 291 | ], 292 | open_time: "09:00:00.000Z", 293 | close_time: "21:00:00.000Z", 294 | slug: "el-catrin-toronto", 295 | location_id: torontoLocationId, 296 | cuisine_id: mexicanCuisineId, 297 | }, 298 | { 299 | name: "Casa Madera", 300 | main_image: 301 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/47744844.jpg", 302 | price: PRICE.EXPENSIVE, 303 | description: 304 | "The first location in Canada, from famed restauranteurs Noble 33, welcomes patrons into an immersive dining experience.", 305 | images: [ 306 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745080.jpg", 307 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745081.jpg", 308 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745093.jpg", 309 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745097.jpg", 310 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745144.jpg", 311 | ], 312 | open_time: "15:00:00.000Z", 313 | close_time: "21:00:00.000Z", 314 | slug: "casa-madera-toronto", 315 | location_id: torontoLocationId, 316 | cuisine_id: mexicanCuisineId, 317 | }, 318 | { 319 | name: "Taco N Tequila", 320 | main_image: 321 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/47429858.jpg", 322 | price: PRICE.CHEAP, 323 | description: 324 | "As a family owned business, our goal is simple: to consistently deliver fresh and delicious Mexican flavours in a FUN and friendly atmosphere with the best service around!", 325 | images: [ 326 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47600418.jpg", 327 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47429797.jpg", 328 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47429802.jpg", 329 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47745097.jpg", 330 | "https://resizer.otstatic.com/v2/photos/xlarge/2/47429814.jpg", 331 | ], 332 | open_time: "10:00:00.000Z", 333 | close_time: "21:00:00.000Z", 334 | slug: "casa-madera-niagara", 335 | location_id: niagaraLocationId, 336 | cuisine_id: mexicanCuisineId, 337 | }, 338 | { 339 | name: "El Jefe", 340 | main_image: 341 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/47710768.jpg", 342 | price: PRICE.CHEAP, 343 | description: 344 | "Lively cantina serving Mexican favorites & potent margaritas in a vibrant, airy space with murals.", 345 | images: [], 346 | open_time: "10:00:00.000Z", 347 | close_time: "21:00:00.000Z", 348 | slug: "el-jefe-niagara", 349 | location_id: niagaraLocationId, 350 | cuisine_id: mexicanCuisineId, 351 | }, 352 | // ITALIAN // 353 | { 354 | name: "Cano Restaurant", 355 | main_image: 356 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/43463549.jpg", 357 | price: PRICE.REGULAR, 358 | description: 359 | "Our back patio has now officially reopened for FOOD SERVICE only. Drinks can be ordered and consumed at the bar before, during, or after dinner service.", 360 | images: [ 361 | "https://resizer.otstatic.com/v2/photos/xlarge/2/43463554.jpg", 362 | "https://resizer.otstatic.com/v2/photos/xlarge/1/43463742.jpg", 363 | "https://resizer.otstatic.com/v2/photos/xlarge/1/43463745.jpg", 364 | "https://resizer.otstatic.com/v2/photos/xlarge/1/43463748.jpg", 365 | "https://resizer.otstatic.com/v2/photos/xlarge/1/43463750.jpg", 366 | "https://resizer.otstatic.com/v2/photos/xlarge/2/43463751.jpg", 367 | ], 368 | open_time: "13:00:00.000Z", 369 | close_time: "21:00:00.000Z", 370 | slug: "cano-restaurant-ottawa", 371 | location_id: ottawaLocationId, 372 | cuisine_id: italianCuisineId, 373 | }, 374 | { 375 | name: "Blu Ristorante", 376 | main_image: 377 | "https://resizer.otstatic.com/v2/photos/wide-huge/2/47350167.jpg", 378 | price: PRICE.EXPENSIVE, 379 | description: 380 | "Victorian Building with two floors of dining space and large side and front patio. Tastefully designed to host your special event, romantic dinner, corporate buyout or a celebration of any sort.", 381 | images: [ 382 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25305566.jpg", 383 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25305567.jpg", 384 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25305568.jpg", 385 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25305569.jpg", 386 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25305570.jpg", 387 | "https://resizer.otstatic.com/v2/photos/xlarge/1/30091570.jpg", 388 | ], 389 | open_time: "15:00:00.000Z", 390 | close_time: "22:00:00.000Z", 391 | slug: "blu-ristorante-ottawa", 392 | location_id: ottawaLocationId, 393 | cuisine_id: italianCuisineId, 394 | }, 395 | { 396 | name: "Stelvio", 397 | main_image: 398 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/50557365.jpg", 399 | price: PRICE.REGULAR, 400 | description: 401 | "Stelvio on Dundas West is an authentic Italian restaurant serving classic old world fare using traditional recipes and ingredients. Recipes have been fine-tuned to satisfy the palate of the modern guest, and fresh meals are prepared daily.", 402 | images: [ 403 | "https://resizer.otstatic.com/v2/photos/xlarge/3/26374971.jpg", 404 | "https://resizer.otstatic.com/v2/photos/xlarge/2/26374974.jpg", 405 | "https://resizer.otstatic.com/v2/photos/xlarge/2/26374975.jpg", 406 | "https://resizer.otstatic.com/v2/photos/xlarge/2/26374976.jpg", 407 | "https://resizer.otstatic.com/v2/photos/xlarge/2/50557389.jpg", 408 | ], 409 | open_time: "13:00:00.000Z", 410 | close_time: "21:00:00.000Z", 411 | slug: "stelvio-ottawa", 412 | location_id: ottawaLocationId, 413 | cuisine_id: italianCuisineId, 414 | }, 415 | { 416 | name: "Terroni Adelaide", 417 | main_image: 418 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/46827195.jpg", 419 | price: PRICE.REGULAR, 420 | description: 421 | "Terroni Adelaide’s multi-level location is located in Toronto’s historic York County Court House circa 1853.", 422 | images: [ 423 | "https://resizer.otstatic.com/v2/photos/xlarge/2/42309468.png", 424 | "https://resizer.otstatic.com/v2/photos/xlarge/2/42309469.png", 425 | "https://resizer.otstatic.com/v2/photos/xlarge/2/42309470.png", 426 | "https://resizer.otstatic.com/v2/photos/xlarge/2/42309472.png", 427 | "https://resizer.otstatic.com/v2/photos/xlarge/2/42309474.png", 428 | ], 429 | open_time: "12:00:00.000Z", 430 | close_time: "18:00:00.000Z", 431 | slug: "terroni-adelaide-niagara", 432 | location_id: niagaraLocationId, 433 | cuisine_id: italianCuisineId, 434 | }, 435 | { 436 | name: "EST Restaurant", 437 | main_image: 438 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/49169798.jpg", 439 | price: PRICE.CHEAP, 440 | description: 441 | "ēst is a modern, newly reopened restaurant serving Italian-French courses, captivating cocktails and wine.", 442 | images: [ 443 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49253937.jpg", 444 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49253940.jpg", 445 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49253941.jpg", 446 | "https://resizer.otstatic.com/v2/photos/xlarge/1/49415599.jpg", 447 | "https://resizer.otstatic.com/v2/photos/xlarge/1/49415604.jpg", 448 | "https://resizer.otstatic.com/v2/photos/xlarge/1/49696221.jpg", 449 | "https://resizer.otstatic.com/v2/photos/xlarge/1/49999039.jpg", 450 | ], 451 | open_time: "09:00:00.000Z", 452 | close_time: "21:00:00.000Z", 453 | slug: "est-restaurant-niagara", 454 | location_id: niagaraLocationId, 455 | cuisine_id: italianCuisineId, 456 | }, 457 | { 458 | name: "Sofia", 459 | main_image: 460 | "https://resizer.otstatic.com/v2/photos/xlarge/1/25558850.jpg", 461 | price: PRICE.EXPENSIVE, 462 | description: 463 | "Tapping into true Italian tastes, the menu starts with a selection of antipasti including a citrus salad and grilled octopus, and a plentiful selection of crudo. ", 464 | images: [ 465 | "https://resizer.otstatic.com/v2/photos/xlarge/25629442.jpg", 466 | "https://resizer.otstatic.com/v2/photos/xlarge/25636273.jpg", 467 | "https://resizer.otstatic.com/v2/photos/xlarge/25679656.jpg", 468 | "https://resizer.otstatic.com/v2/photos/xlarge/25825772.jpg", 469 | "https://resizer.otstatic.com/v2/photos/xlarge/26011606.jpg", 470 | ], 471 | open_time: "13:00:00.000Z", 472 | close_time: "21:00:00.000Z", 473 | slug: "sofia-toronto", 474 | location_id: torontoLocationId, 475 | cuisine_id: italianCuisineId, 476 | }, 477 | { 478 | name: "Terroni Sud Forno", 479 | main_image: 480 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/49463645.png", 481 | price: PRICE.REGULAR, 482 | description: 483 | "Spaccio West, near the Lower Junction on the West Toronto Railpath, acts as the backstage to the main show taking place at all Terroni locations.", 484 | images: [ 485 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48741813.jpg", 486 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48741816.jpg", 487 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48741821.jpg", 488 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48741826.jpg", 489 | "https://resizer.otstatic.com/v2/photos/xlarge/2/48741827.jpg", 490 | ], 491 | open_time: "10:00:00.000Z", 492 | close_time: "21:00:00.000Z", 493 | slug: "terroni-sud-forno-produzione-e-spaccio-toronto", 494 | location_id: torontoLocationId, 495 | cuisine_id: italianCuisineId, 496 | }, 497 | { 498 | name: "il Padrino", 499 | main_image: 500 | "https://resizer.otstatic.com/v2/photos/wide-huge/3/49616181.jpg", 501 | price: PRICE.CHEAP, 502 | description: 503 | "Welcome to the newest edition to College street iL PADRINO Ristorante has joined the list of Italian restaurants where Chef Connie award winning Italian Chef makes every Italian dish with love like no other. ", 504 | images: [ 505 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49494556.jpg", 506 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49494562.jpg", 507 | "https://resizer.otstatic.com/v2/photos/xlarge/2/49494563.jpg", 508 | "https://resizer.otstatic.com/v2/photos/xlarge/3/49494887.jpg", 509 | "https://resizer.otstatic.com/v2/photos/xlarge/3/49533502.jpg", 510 | ], 511 | open_time: "07:00:00.000Z", 512 | close_time: "21:00:00.000Z", 513 | slug: "il-padrino-toronto", 514 | location_id: torontoLocationId, 515 | cuisine_id: italianCuisineId, 516 | }, 517 | ], 518 | }); 519 | 520 | const restaurants = await prisma.restaurant.findMany(); 521 | 522 | const vivaanId = 523 | restaurants.find((restaurant) => restaurant.name === "Vivaan - fine Indian") 524 | ?.id || 1; 525 | const RamaKrishnaId = 526 | restaurants.find((restaurant) => restaurant.name === "RamaKrishna Indian") 527 | ?.id || 1; 528 | const coconutLagoonId = 529 | restaurants.find((restaurant) => restaurant.name === "Coconut Lagoon") 530 | ?.id || 1; 531 | const lastTrainToDelhiId = 532 | restaurants.find((restaurant) => restaurant.name === "Last Train to Delhi") 533 | ?.id || 1; 534 | const adrakYorkvilleId = 535 | restaurants.find((restaurant) => restaurant.name === "Adrak Yorkville") 536 | ?.id || 1; 537 | const curryishTavernId = 538 | restaurants.find((restaurant) => restaurant.name === "Curryish Tavern") 539 | ?.id || 1; 540 | const utsavId = 541 | restaurants.find((restaurant) => restaurant.name === "Utsav")?.id || 1; 542 | const pukkaId = 543 | restaurants.find((restaurant) => restaurant.name === "Pukka")?.id || 1; 544 | const kamasutraIndianId = 545 | restaurants.find((restaurant) => restaurant.name === "Kamasutra Indian") 546 | ?.id || 1; 547 | const eldoradoTacoId = 548 | restaurants.find((restaurant) => restaurant.name === "Eldorado Taco")?.id || 549 | 1; 550 | const laBartolaId = 551 | restaurants.find((restaurant) => restaurant.name === "La Bartola")?.id || 1; 552 | const elCatrinId = 553 | restaurants.find((restaurant) => restaurant.name === "El Catrin")?.id || 1; 554 | const mariachisId = 555 | restaurants.find((restaurant) => restaurant.name === "3 Mariachis")?.id || 556 | 1; 557 | const canoRestaurantId = 558 | restaurants.find((restaurant) => restaurant.name === "Cano Restaurant") 559 | ?.id || 1; 560 | const bluRistoranteId = 561 | restaurants.find((restaurant) => restaurant.name === "Blu Ristorante") 562 | ?.id || 1; 563 | const stelvioId = 564 | restaurants.find((restaurant) => restaurant.name === "Stelvio")?.id || 1; 565 | const sofiaId = 566 | restaurants.find((restaurant) => restaurant.name === "Sofia")?.id || 1; 567 | 568 | await prisma.item.createMany({ 569 | data: [ 570 | { 571 | name: "Ghee roast chicken wings", 572 | description: 573 | "Crispy chicken wings coated in a sauce made from roasted whole spices and clarified butter.", 574 | price: "$18.00", 575 | restaurant_id: vivaanId, 576 | }, 577 | { 578 | name: "Sol Kadhi scallop ceviche", 579 | description: "Cured scallop served with mangosteen and coconut broth", 580 | price: "$18.00", 581 | restaurant_id: vivaanId, 582 | }, 583 | { 584 | name: "Butte ka kees", 585 | description: 586 | "Bhutte( Corn) Khees( grated) and spiced and tempered served with waffers", 587 | price: "$17.00", 588 | restaurant_id: vivaanId, 589 | }, 590 | { 591 | name: "Burrata Paapdi Chaat", 592 | description: 593 | "Our house made paapdi served with spiced potatoes and burrata cheese dressed with in house chutneys", 594 | price: "$16.00", 595 | restaurant_id: vivaanId, 596 | }, 597 | { 598 | name: "Shaadi Waala Chicken Curry", 599 | description: 600 | "Chicken curry usually served in weddings back home (Must Try)", 601 | price: "$26.00", 602 | restaurant_id: vivaanId, 603 | }, 604 | { 605 | name: "Shahi Tukda", 606 | description: 607 | "Chef’s signature dessert : crispy bread poched with flavoured milk and topped with homemade cream made of pistachios, rose.", 608 | price: "$11.00", 609 | restaurant_id: vivaanId, 610 | }, 611 | { 612 | name: "Four-In-One Chicken", 613 | description: 614 | "Boneless chicken breast pieces marinated with four different kind of texture and Indian spices for each piece and grilled in clay oven", 615 | price: "$16.99", 616 | restaurant_id: RamaKrishnaId, 617 | }, 618 | { 619 | name: "Chicken Tikka", 620 | description: 621 | "Boneless Chicken marinated overnight with yogurt, Indian spices and cooked in a Tandoor oven", 622 | price: "$16.99", 623 | restaurant_id: RamaKrishnaId, 624 | }, 625 | { 626 | name: "Paneer Tikka", 627 | description: 628 | "Tandoori Paneer Tikka is made from homemade cottage cheese which is marinated in yogurt and dry aromatic Indian spices along with diced onions and capsicum and grilled in clay oven", 629 | price: "$16.99", 630 | restaurant_id: RamaKrishnaId, 631 | }, 632 | { 633 | name: "Fish Tikka", 634 | description: 635 | "Deboned fish marinated in ginger, garlic and other spices and grilled in clay oven", 636 | price: "$16.99", 637 | restaurant_id: RamaKrishnaId, 638 | }, 639 | { 640 | name: "Prawn Tandoori", 641 | description: 642 | "Large juicy prawn marinated in ginger, garlic, fresh squeezed lemon juice and along with various dry spices and grilled in clay oven", 643 | price: "$19.49", 644 | restaurant_id: RamaKrishnaId, 645 | }, 646 | { 647 | name: "Mixed Grill", 648 | description: 649 | "Tandoori chicken, lamb tikka, chicken tikka and fish grilled in our clay oven", 650 | price: "$20.99", 651 | restaurant_id: RamaKrishnaId, 652 | }, 653 | { 654 | name: "Coconut Curry", 655 | description: 656 | "Choice of boneless chicken breast, lamb, beef, fish or shrimp cooked in a creamy coconut, butter and onion sauce", 657 | price: "15.99", 658 | restaurant_id: RamaKrishnaId, 659 | }, 660 | { 661 | name: "Quilon Chicken", 662 | description: 663 | "free range grass fed chicken cooked in a tangy tomato masala", 664 | price: "$25.00", 665 | restaurant_id: coconutLagoonId, 666 | }, 667 | { 668 | name: "Mariposa's Duck Biryani**", 669 | description: "slow baked in kiama rice, quail egg and raita", 670 | price: "$26.00", 671 | restaurant_id: coconutLagoonId, 672 | }, 673 | { 674 | name: "Pala Lamb Peralan", 675 | description: "tender morsels of lamb in an exotic masala", 676 | price: "$26.00", 677 | restaurant_id: coconutLagoonId, 678 | }, 679 | { 680 | name: "Roasted Salmon In Moilee Sauce", 681 | description: "marinated in green mango, spices and roasted", 682 | price: "$27.00", 683 | restaurant_id: coconutLagoonId, 684 | }, 685 | { 686 | name: "Vegetable Aviyal", 687 | description: 688 | "assorted vegetables cooked in yoghurt, coconut spiked with cumin", 689 | price: "$22.00", 690 | restaurant_id: coconutLagoonId, 691 | }, 692 | { 693 | name: "Aloo Tiki", 694 | description: 695 | "Potato croquette topped with pickled seasonal vegetables and an assortment of chutneys", 696 | price: "$12.00", 697 | restaurant_id: lastTrainToDelhiId, 698 | }, 699 | { 700 | name: "Spicy Lamb Chops", 701 | description: 702 | "Lamb chops are coated in a spicy marinade and seared. It's paired with mint chutney, mango chutney, and raita. Allergens: Meat", 703 | price: "16.00", 704 | restaurant_id: lastTrainToDelhiId, 705 | }, 706 | { 707 | name: "Crispy Shrimp", 708 | description: 709 | "Tandoori shrimp wrapped in crispy potato accompanied by a seasonal chutney and micro greens from the garden", 710 | price: "$15.00", 711 | restaurant_id: lastTrainToDelhiId, 712 | }, 713 | { 714 | name: "Bhaingan Bharta", 715 | description: "Smokey eggplant and peas", 716 | price: "$17.00", 717 | restaurant_id: lastTrainToDelhiId, 718 | }, 719 | { 720 | name: "Kofta Curry", 721 | description: 722 | "Indian kofta served with bottleneck gourds and potatoes in a cashew coconut sauce", 723 | price: "$20.00", 724 | restaurant_id: lastTrainToDelhiId, 725 | }, 726 | { 727 | name: "murgh salaad", 728 | description: "Chicken breast, mix greens, mint vinegar dressing", 729 | price: "$18.00", 730 | restaurant_id: adrakYorkvilleId, 731 | }, 732 | { 733 | name: "papad ki tokri", 734 | description: "Papadams, assorted chutneys & salsa", 735 | price: "$18.00", 736 | restaurant_id: adrakYorkvilleId, 737 | }, 738 | { 739 | name: "khumb korma", 740 | description: 741 | "Aged basmati rice, marinated lamb & puff pastry cover, garlic yogurt", 742 | price: "$36.00", 743 | restaurant_id: adrakYorkvilleId, 744 | }, 745 | { 746 | name: "dal tadka", 747 | description: "Yellow lentils, indian tempering", 748 | price: "$20.00", 749 | restaurant_id: adrakYorkvilleId, 750 | }, 751 | { 752 | name: "cocochoco rasmalai cheese cake", 753 | description: 754 | "Coconut crémeux, chocolate hazelnut crunch, coconut snow, citrus gel, cardamom ice cream", 755 | price: "$19.00", 756 | restaurant_id: adrakYorkvilleId, 757 | }, 758 | { 759 | name: "Molasses Braised Beef Cheeks Curry", 760 | description: 761 | "Caramelised root vegetables, deggi mirch, buttermilk onion rings", 762 | price: "$32.00", 763 | restaurant_id: curryishTavernId, 764 | }, 765 | { 766 | name: "Coconut Vatan Stuffed Whole Branzino", 767 | description: "Turmeric lemon butter sauce, curry leaves, mustard seeds", 768 | price: "$39.00", 769 | restaurant_id: curryishTavernId, 770 | }, 771 | { 772 | name: "Goan Chorizo + Braised Pork Shoulder Curry", 773 | description: 774 | "Double smoked bacon, roasted parsnips, red kidney beans, apple achar", 775 | price: "$31.00", 776 | restaurant_id: curryishTavernId, 777 | }, 778 | { 779 | name: "Screech Rum Soaked Gulab Jamun", 780 | description: "Whipped mascarpone cream, pistachio crumble", 781 | price: "$14.00", 782 | restaurant_id: curryishTavernId, 783 | }, 784 | { 785 | name: "Ontario Apple + Almond Halwa Tart", 786 | description: "Whipped cinnamon malai, red currants", 787 | price: "$14.00", 788 | restaurant_id: curryishTavernId, 789 | }, 790 | { 791 | name: "Vegetable samosa", 792 | description: "Seasoned potatoes and peas wrapped in a light pastry", 793 | price: "$4.00", 794 | restaurant_id: utsavId, 795 | }, 796 | { 797 | name: "Goan fish curry", 798 | description: 799 | "Filet of salmon cooked in a traditional hot and tangy coconut curry", 800 | price: "$15.00", 801 | restaurant_id: utsavId, 802 | }, 803 | { 804 | name: "Lamb vindaloo", 805 | description: 806 | "A delicacy from Goa - Boneless lamb cooked in a hot, spicy and tangy sauce with potatoes", 807 | price: "$14.00", 808 | restaurant_id: utsavId, 809 | }, 810 | { 811 | name: "Matar paneer", 812 | description: 813 | "Cottage cheese and green peas cooked in butter flavored onion and tomato gravy", 814 | price: "$10.00", 815 | restaurant_id: utsavId, 816 | }, 817 | { 818 | name: "Chicken vindaloo", 819 | description: 820 | "Chicken cooked with herbs and spices in special hot spicy and tangy sauce with potatoes", 821 | price: "$14.00", 822 | restaurant_id: utsavId, 823 | }, 824 | { 825 | name: "Chicken jalfrezi", 826 | description: 827 | "Chicken cooked with delicious mix of green peppers, onions, green chillies and tomatoes", 828 | price: "$14.00", 829 | restaurant_id: utsavId, 830 | }, 831 | { 832 | name: "Lamb Lollipops", 833 | description: "grilled chops with turmeric, mint and fenugreek curry", 834 | price: "$44.00", 835 | restaurant_id: pukkaId, 836 | }, 837 | { 838 | name: "Vegan Tikka Masala", 839 | description: "tofu, sweet peppers, red onion, tomato and cashew cream", 840 | price: "$23.00", 841 | restaurant_id: pukkaId, 842 | }, 843 | { 844 | name: "Short Ribs", 845 | description: 846 | "PEI beef braised with black cumin, cloves, cardamom and fennel seeds", 847 | price: "$32.00", 848 | restaurant_id: pukkaId, 849 | }, 850 | { 851 | name: "Punjabi Chicken Curry", 852 | description: "spicy home-style chicken curry", 853 | price: "$24.00", 854 | restaurant_id: pukkaId, 855 | }, 856 | { 857 | name: "Pukka Chaat", 858 | description: 859 | "string vegetables, sprouts, rice crisps, pomegranate, mango, green apple, chutneys and yoghurt", 860 | price: "$16.00", 861 | restaurant_id: pukkaId, 862 | }, 863 | { 864 | name: "Chicken Tikka", 865 | description: 866 | "herb-infused white meat, tandoor roasted and served with tamarind chutney", 867 | price: "$21.00", 868 | restaurant_id: pukkaId, 869 | }, 870 | { 871 | name: "Butter Chicken Poutine", 872 | description: 873 | "Fries are served topped with melting cheese and butter chicken gravy", 874 | price: "$8.99", 875 | restaurant_id: kamasutraIndianId, 876 | }, 877 | { 878 | name: "Vegetable Appy Platter", 879 | description: 880 | "2 Vegetable samosas, vegetable pakora, paneer pakora, 1 papadum, served with chickpea curry", 881 | price: "$13.99", 882 | restaurant_id: kamasutraIndianId, 883 | }, 884 | { 885 | name: "Pulled Chicken", 886 | description: "marinated chicken with salsa", 887 | price: "12.00", 888 | restaurant_id: eldoradoTacoId, 889 | }, 890 | { 891 | name: "Fettuccine Pescatore", 892 | description: "Scallops, mussels, shrimp and crab meat in a rose sauce", 893 | price: "$33.00", 894 | restaurant_id: laBartolaId, 895 | }, 896 | { 897 | name: "Colosseo Pizze", 898 | description: 899 | "Luciano's spicy Italian sausage, black olives, hot peppers, mozzarella and parmigiano cheeses", 900 | price: "$22.00", 901 | restaurant_id: laBartolaId, 902 | }, 903 | { 904 | name: "Vitello alla Griglia", 905 | description: 906 | "Grilled veal medallion, with seasonal vegetables and potatoes", 907 | price: "$35.00", 908 | restaurant_id: laBartolaId, 909 | }, 910 | { 911 | name: "Agnello", 912 | description: 913 | "Grilled lamb chops in a citrus marinade, with seasonal vegetables and potatoes", 914 | price: "$35.00", 915 | restaurant_id: laBartolaId, 916 | }, 917 | { 918 | name: "Orata ai Porri", 919 | description: "$32.00", 920 | price: 921 | "Pan seared sea bream filet with sautéed leeks, served over a wild rice medley and greens", 922 | restaurant_id: laBartolaId, 923 | }, 924 | { 925 | name: "Insalata di Mare", 926 | description: 927 | "Mixed greens tossed in our house viniagriette, topped with grilled shrimp and crab meat", 928 | price: "$25.00", 929 | restaurant_id: laBartolaId, 930 | }, 931 | { 932 | name: "PASTOR", 933 | description: 934 | "Marinated shaved pork, pineapple, red onion dice, cilantro, salsa verde, corn tortilla", 935 | price: "$23.00", 936 | restaurant_id: elCatrinId, 937 | }, 938 | { 939 | name: "COCHINITA PIBIL", 940 | description: 941 | "Achiote rubbed pork, black bean puree, pickled red onion, cilantro, habanero salsa", 942 | price: "$23.00", 943 | restaurant_id: elCatrinId, 944 | }, 945 | { 946 | name: "Seafood Molcajete", 947 | description: "Grilled calamari, morita garlic shrimp, octopus", 948 | price: "$23.00", 949 | restaurant_id: mariachisId, 950 | }, 951 | { 952 | name: "Sirloin Steak & Tuetano Osso Buco", 953 | description: 954 | "Bone marrow, slow cooked in the oven, topped with our seasoning", 955 | price: "$26.00", 956 | restaurant_id: mariachisId, 957 | }, 958 | { 959 | name: "Fajitas", 960 | description: 961 | "A sizzling bed of onions and bell peppers topped with your choice of protein", 962 | price: "$17.50", 963 | restaurant_id: mariachisId, 964 | }, 965 | { 966 | name: "Hamachi", 967 | description: 968 | "Ponzu à la truffe, truffe noire râpée [Salmon Tataki, Truffle ponzu, Shaved black truffle]", 969 | price: "$24.00", 970 | restaurant_id: canoRestaurantId, 971 | }, 972 | { 973 | name: "Tartare de Thon", 974 | description: 975 | "Soja Yuzu, piment serrano [Hot Hamachi, Yuzu soy, Serrano pepper]", 976 | price: "$24.00", 977 | restaurant_id: canoRestaurantId, 978 | }, 979 | { 980 | name: "Tataki de Saumon", 981 | description: 982 | "Purée d'avocat, chili soja [Tuna Tartar, Avocado puree, Chili soy]", 983 | price: "$27.00", 984 | restaurant_id: canoRestaurantId, 985 | }, 986 | { 987 | name: "Tomato Braised Beef Cheek Ragu", 988 | description: 989 | "Wild Mushrooms, Sweet Potato & Ricotta Gnocchi, Fresh Basil", 990 | price: "$29.00", 991 | restaurant_id: bluRistoranteId, 992 | }, 993 | { 994 | name: "Roasted Butternut Squash Ravioli", 995 | description: 996 | "Gorgonzola, Balsamic Reduction, Brown Butter, Crispy Sage", 997 | price: "$33.00", 998 | restaurant_id: bluRistoranteId, 999 | }, 1000 | { 1001 | name: "Pan Seared Atlantic Salmon", 1002 | description: 1003 | "Heirloom Carrots, Green Beans, Parsnip Puree, Beluga Lentils & Barley, Chive Oil", 1004 | price: "$33.00", 1005 | restaurant_id: bluRistoranteId, 1006 | }, 1007 | { 1008 | name: "Woodfire Grilled 12oz AAA Ribeye", 1009 | description: 1010 | "Heirloom Carrots, Green Beans, Sweet Potato Gratin, Mushroom Veal jus", 1011 | price: "$55.00", 1012 | restaurant_id: bluRistoranteId, 1013 | }, 1014 | { 1015 | name: "Pizzoccheri di Teglio", 1016 | description: 1017 | "Homemade short buckwheat Pasta coated in three-cheese sauce, savoy cabbage, potatoes, butter and sage", 1018 | price: "$24.00", 1019 | restaurant_id: stelvioId, 1020 | }, 1021 | { 1022 | name: "Gnocchi al Gorgonzola", 1023 | description: "Fresh homemade Gnocchi served in a blue cheese sauce", 1024 | price: "$23.00", 1025 | restaurant_id: stelvioId, 1026 | }, 1027 | { 1028 | name: "Risotto ai Funghi", 1029 | description: "Aironi Carnaroli risotto served with mushrooms", 1030 | price: "$26.00", 1031 | restaurant_id: stelvioId, 1032 | }, 1033 | { 1034 | name: "Spezzatino con Polenta", 1035 | description: 1036 | "Traditional Northern Italian Specialty. Slow-cooked feef stew, cooked in tomato sauce and red wine reduction, served over soft polenta", 1037 | price: "$26.00", 1038 | restaurant_id: stelvioId, 1039 | }, 1040 | ], 1041 | }); 1042 | 1043 | const userLaith = await prisma.user.create({ 1044 | data: { 1045 | first_name: "Laith", 1046 | last_name: "Harb", 1047 | email: "laith@hotmail.com", 1048 | city: "ottawa", 1049 | password: "$2b$10$I8xkU2nQ8EAHuVOdbMy9YO/.rSU3584Y.H4LrpIujGNDtmny9FnLu", 1050 | phone: "1112223333", 1051 | }, 1052 | }); 1053 | 1054 | const userJosh = await prisma.user.create({ 1055 | data: { 1056 | first_name: "Josh", 1057 | last_name: "Allen", 1058 | email: "josh@hotmail.com", 1059 | city: "toronto", 1060 | password: "$2b$10$I8xkU2nQ8EAHuVOdbMy9YO/.rSU3584Y.H4LrpIujGNDtmny9FnLu", 1061 | phone: "1112223333", 1062 | }, 1063 | }); 1064 | 1065 | const userLebron = await prisma.user.create({ 1066 | data: { 1067 | first_name: "LeBron", 1068 | last_name: "James", 1069 | email: "lebron@hotmail.com", 1070 | city: "niagara", 1071 | password: "$2b$10$I8xkU2nQ8EAHuVOdbMy9YO/.rSU3584Y.H4LrpIujGNDtmny9FnLu", 1072 | phone: "1112223333", 1073 | }, 1074 | }); 1075 | 1076 | const userCassidy = await prisma.user.create({ 1077 | data: { 1078 | first_name: "Cassidy", 1079 | last_name: "Marksom", 1080 | email: "cassidy@hotmail.com", 1081 | city: "toronto", 1082 | password: "$2b$10$I8xkU2nQ8EAHuVOdbMy9YO/.rSU3584Y.H4LrpIujGNDtmny9FnLu", 1083 | phone: "1112223333", 1084 | }, 1085 | }); 1086 | 1087 | await prisma.review.createMany({ 1088 | data: [ 1089 | { 1090 | first_name: "Laith", 1091 | last_name: "Harb", 1092 | text: "This place is amazing, it has some of the best dishes in the world. It is so so so good!!!", 1093 | rating: 5, 1094 | restaurant_id: vivaanId, 1095 | user_id: userLaith.id, 1096 | }, 1097 | { 1098 | first_name: "Laith", 1099 | last_name: "Harb", 1100 | text: "This food is so good! It is the fanciest thing I have ever seen in my short life", 1101 | rating: 5, 1102 | restaurant_id: bluRistoranteId, 1103 | user_id: userLaith.id, 1104 | }, 1105 | { 1106 | first_name: "Laith", 1107 | last_name: "Harb", 1108 | text: "Excellent food and service. Busy night, but everything was great! Highly recommend.", 1109 | rating: 5, 1110 | restaurant_id: elCatrinId, 1111 | user_id: userLaith.id, 1112 | }, 1113 | { 1114 | first_name: "Laith", 1115 | last_name: "Harb", 1116 | text: "Very nice place for a date night, the service was fast and friendly. The food was amazing.", 1117 | rating: 4, 1118 | restaurant_id: stelvioId, 1119 | user_id: userLaith.id, 1120 | }, 1121 | { 1122 | first_name: "Laith", 1123 | last_name: "Harb", 1124 | text: "Extremely disappointing in our experience.", 1125 | rating: 2, 1126 | restaurant_id: laBartolaId, 1127 | user_id: userLaith.id, 1128 | }, 1129 | { 1130 | first_name: "Laith", 1131 | last_name: "Harb", 1132 | text: "This place is amazing, it has some of the best dishes in the world. It is so so so good!!!", 1133 | rating: 5, 1134 | restaurant_id: elCatrinId, 1135 | user_id: userLaith.id, 1136 | }, 1137 | { 1138 | first_name: "Laith", 1139 | last_name: "Harb", 1140 | text: "As always, food was excellent. Waitress was friendly and prompt. We had just one problem in that our dessert order went rogue in the system and we waited ages for it to arrive.", 1141 | rating: 5, 1142 | restaurant_id: kamasutraIndianId, 1143 | user_id: userLaith.id, 1144 | }, 1145 | { 1146 | first_name: "Laith", 1147 | last_name: "Harb", 1148 | text: "Restaurant was loud and crowded. Food is not worth the price.", 1149 | rating: 3, 1150 | restaurant_id: eldoradoTacoId, 1151 | user_id: userLaith.id, 1152 | }, 1153 | { 1154 | first_name: "Josh", 1155 | last_name: "Allen", 1156 | text: "A Christmas lunch with clients served by a friendly server with a good wine selection everyone enjoyed the appetizers and meals", 1157 | rating: 4, 1158 | restaurant_id: vivaanId, 1159 | user_id: userJosh.id, 1160 | }, 1161 | { 1162 | first_name: "Josh", 1163 | last_name: "Allen", 1164 | text: "The food was very tasty, the price is a little high so a place to go only for special occasions", 1165 | rating: 5, 1166 | restaurant_id: sofiaId, 1167 | user_id: userJosh.id, 1168 | }, 1169 | { 1170 | first_name: "Josh", 1171 | last_name: "Allen", 1172 | text: "Had a great time at Chops. Our server Dane was super friendly. Dinner was delicious as always.", 1173 | rating: 3, 1174 | restaurant_id: curryishTavernId, 1175 | user_id: userJosh.id, 1176 | }, 1177 | { 1178 | first_name: "Josh", 1179 | last_name: "Allen", 1180 | text: "The service was poor as we had to wait a long time for our food. There were some issues but were dealt with in a proper manner.", 1181 | rating: 3, 1182 | restaurant_id: adrakYorkvilleId, 1183 | user_id: userJosh.id, 1184 | }, 1185 | { 1186 | first_name: "Josh", 1187 | last_name: "Allen", 1188 | text: "Wonderful food and service.", 1189 | rating: 5, 1190 | restaurant_id: coconutLagoonId, 1191 | user_id: userJosh.id, 1192 | }, 1193 | { 1194 | first_name: "Josh", 1195 | last_name: "Allen", 1196 | text: "Great food, great staff. You can’t ask for much more from a restaurant.", 1197 | rating: 5, 1198 | restaurant_id: bluRistoranteId, 1199 | user_id: userJosh.id, 1200 | }, 1201 | { 1202 | first_name: "LeBron", 1203 | last_name: "James", 1204 | text: "Wonderful service! Delicious food! Comfortable seating and luxurious atmosphere.", 1205 | rating: 5, 1206 | restaurant_id: RamaKrishnaId, 1207 | user_id: userLebron.id, 1208 | }, 1209 | { 1210 | first_name: "LeBron", 1211 | last_name: "James", 1212 | text: "Prime rib and filet were made spot on. Vegetable sides were made well as was the shrimp and scallops.", 1213 | rating: 4, 1214 | restaurant_id: lastTrainToDelhiId, 1215 | user_id: userLebron.id, 1216 | }, 1217 | { 1218 | first_name: "LeBron", 1219 | last_name: "James", 1220 | text: "This visit was with a friend who had never been here before. She loved it as much as I do. She said it will be our new go to place!", 1221 | rating: 4, 1222 | restaurant_id: curryishTavernId, 1223 | user_id: userLebron.id, 1224 | }, 1225 | { 1226 | first_name: "LeBron", 1227 | last_name: "James", 1228 | text: "Had a full 3 course meal in the mid afternoon and every aspect of it was great. Server was named Brittany I believe and she was simply excellent.", 1229 | rating: 5, 1230 | restaurant_id: pukkaId, 1231 | user_id: userLebron.id, 1232 | }, 1233 | { 1234 | first_name: "LeBron", 1235 | last_name: "James", 1236 | text: "Very nice evening spent with special family.", 1237 | rating: 5, 1238 | restaurant_id: mariachisId, 1239 | user_id: userLebron.id, 1240 | }, 1241 | { 1242 | first_name: "LeBron", 1243 | last_name: "James", 1244 | text: "First time, and not the last. Very welcoming. The food was deliscious and service very good. Highly recommend.", 1245 | rating: 4, 1246 | restaurant_id: eldoradoTacoId, 1247 | user_id: userLebron.id, 1248 | }, 1249 | { 1250 | first_name: "Cassidy", 1251 | last_name: "Mancher", 1252 | text: "Enjoyed our drinks, dinner and dessert. Great service and ambience.", 1253 | rating: 5, 1254 | restaurant_id: mariachisId, 1255 | user_id: userCassidy.id, 1256 | }, 1257 | { 1258 | first_name: "Cassidy", 1259 | last_name: "Mancher", 1260 | text: "The food was absolutely on point, we had such a great experience and our server was top notch. ", 1261 | rating: 4, 1262 | restaurant_id: stelvioId, 1263 | user_id: userCassidy.id, 1264 | }, 1265 | { 1266 | first_name: "Cassidy", 1267 | last_name: "Mancher", 1268 | text: "The steaks were 'Melt In Your Mouth'!!! Nigel, our waiter was amazing!! Great experience overall!", 1269 | rating: 5, 1270 | restaurant_id: coconutLagoonId, 1271 | user_id: userCassidy.id, 1272 | }, 1273 | { 1274 | first_name: "Cassidy", 1275 | last_name: "Mancher", 1276 | text: "It was really great! Just temperature wise it was really chilly. A little mixup at the end with desserts also but overall we really enjoyed the evening", 1277 | rating: 4, 1278 | restaurant_id: bluRistoranteId, 1279 | user_id: userCassidy.id, 1280 | }, 1281 | { 1282 | first_name: "Cassidy", 1283 | last_name: "Mancher", 1284 | text: "Food was served cold. Major No No. Fantastic Dessert. Service was good. Heavy Rock music should be toned down. Price vs Quality… not great.", 1285 | rating: 3, 1286 | restaurant_id: laBartolaId, 1287 | user_id: userCassidy.id, 1288 | }, 1289 | { 1290 | first_name: "Cassidy", 1291 | last_name: "Mancher", 1292 | text: "Fantastic food and excellent selection. Everything was fresh - and the scones were still warm!", 1293 | rating: 4, 1294 | restaurant_id: eldoradoTacoId, 1295 | user_id: userCassidy.id, 1296 | }, 1297 | { 1298 | first_name: "Cassidy", 1299 | last_name: "Mancher", 1300 | text: "Fantastic food and excellent selection. Everything was fresh - and the scones were still warm!", 1301 | rating: 4, 1302 | restaurant_id: utsavId, 1303 | user_id: userCassidy.id, 1304 | }, 1305 | ], 1306 | }); 1307 | 1308 | await prisma.table.createMany({ 1309 | data: [ 1310 | { 1311 | restaurant_id: vivaanId, 1312 | seats: 4, 1313 | }, 1314 | { 1315 | restaurant_id: vivaanId, 1316 | seats: 4, 1317 | }, 1318 | { 1319 | restaurant_id: vivaanId, 1320 | seats: 2, 1321 | }, 1322 | ], 1323 | }); 1324 | 1325 | res.status(200).json({ name: "hello" }); 1326 | } 1327 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "postgresql" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model Restaurant { 14 | id Int @id @default(autoincrement()) 15 | name String 16 | main_image String 17 | images String[] 18 | description String 19 | open_time String 20 | close_time String 21 | slug String @unique 22 | price PRICE 23 | items Item[] 24 | location_id Int 25 | location Location @relation(fields: [location_id], references: [id]) 26 | cuisine_id Int 27 | cuisine Cuisine @relation(fields: [cuisine_id], references: [id]) 28 | reviews Review[] 29 | bookings Booking[] 30 | tables Table[] 31 | created_at DateTime @default(now()) 32 | updated_at DateTime @updatedAt 33 | } 34 | 35 | model Item { 36 | id Int @id @default(autoincrement()) 37 | name String 38 | price String 39 | description String 40 | restaurant_id Int 41 | restaurant Restaurant @relation(fields: [restaurant_id], references: [id]) 42 | created_at DateTime @default(now()) 43 | updated_at DateTime @updatedAt 44 | } 45 | 46 | model Location { 47 | id Int @id @default(autoincrement()) 48 | name String 49 | restaurants Restaurant[] 50 | created_at DateTime @default(now()) 51 | updated_at DateTime @updatedAt 52 | } 53 | 54 | model Cuisine { 55 | id Int @id @default(autoincrement()) 56 | name String 57 | restaurants Restaurant[] 58 | created_at DateTime @default(now()) 59 | updated_at DateTime @updatedAt 60 | } 61 | 62 | model User { 63 | id Int @id @default(autoincrement()) 64 | first_name String 65 | last_name String 66 | city String 67 | password String 68 | email String @unique 69 | phone String 70 | reviews Review[] 71 | created_at DateTime @default(now()) 72 | updated_at DateTime @updatedAt 73 | } 74 | 75 | model Review { 76 | id Int @id @default(autoincrement()) 77 | first_name String 78 | last_name String 79 | text String 80 | rating Float 81 | restaurant_id Int 82 | restaurant Restaurant @relation(fields: [restaurant_id], references: [id]) 83 | user_id Int 84 | user User @relation(fields: [user_id], references: [id]) 85 | } 86 | 87 | model Booking { 88 | id Int @id @default(autoincrement()) 89 | number_of_people Int 90 | booking_time DateTime 91 | booker_email String 92 | booker_phone String 93 | booker_first_name String 94 | booker_last_name String 95 | booker_occasion String? 96 | booker_request String? 97 | restaurant_id Int 98 | restaurant Restaurant @relation(fields: [restaurant_id], references: [id]) 99 | tables BookingsOnTables[] 100 | created_at DateTime @default(now()) 101 | updated_at DateTime @updatedAt 102 | } 103 | 104 | model Table { 105 | id Int @id @default(autoincrement()) 106 | seats Int 107 | restaurant_id Int 108 | restaurant Restaurant @relation(fields: [restaurant_id], references: [id]) 109 | bookings BookingsOnTables[] 110 | created_at DateTime @default(now()) 111 | updated_at DateTime @updatedAt 112 | } 113 | 114 | model BookingsOnTables { 115 | booking_id Int 116 | booking Booking @relation(fields: [booking_id], references: [id]) 117 | table_id Int 118 | table Table @relation(fields: [table_id], references: [id]) 119 | created_at DateTime @default(now()) 120 | updated_at DateTime @updatedAt 121 | 122 | @@id([booking_id, table_id]) 123 | } 124 | 125 | enum PRICE { 126 | CHEAP 127 | REGULAR 128 | EXPENSIVE 129 | } 130 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/favicon.ico -------------------------------------------------------------------------------- /public/icons/empty-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/icons/empty-star.png -------------------------------------------------------------------------------- /public/icons/error.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/icons/error.png -------------------------------------------------------------------------------- /public/icons/full-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/icons/full-star.png -------------------------------------------------------------------------------- /public/icons/half-star.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/icons/half-star.png -------------------------------------------------------------------------------- /public/icons8-restaurant-on-site-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/harblaith7/Next13-Udemy-Course/4a5d4e2ebad175ff9aeb25f05247927909724de8/public/icons8-restaurant-on-site-16.png -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/thirteen.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /services/restaurant/findAvailableTables.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient, Table } from "@prisma/client"; 2 | import { NextApiResponse } from "next"; 3 | import { times } from "../../data"; 4 | 5 | const prisma = new PrismaClient(); 6 | 7 | export const findAvailabileTables = async ({ 8 | time, 9 | day, 10 | res, 11 | restaurant, 12 | }: { 13 | time: string; 14 | day: string; 15 | res: NextApiResponse; 16 | restaurant: { 17 | tables: Table[]; 18 | open_time: string; 19 | close_time: string; 20 | }; 21 | }) => { 22 | const searchTimes = times.find((t) => { 23 | return t.time === time; 24 | })?.searchTimes; 25 | 26 | if (!searchTimes) { 27 | return res.status(400).json({ 28 | errorMessage: "Invalid data provided", 29 | }); 30 | } 31 | 32 | const bookings = await prisma.booking.findMany({ 33 | where: { 34 | booking_time: { 35 | gte: new Date(`${day}T${searchTimes[0]}`), 36 | lte: new Date(`${day}T${searchTimes[searchTimes.length - 1]}`), 37 | }, 38 | }, 39 | select: { 40 | number_of_people: true, 41 | booking_time: true, 42 | tables: true, 43 | }, 44 | }); 45 | 46 | const bookingTablesObj: { [key: string]: { [key: number]: true } } = {}; 47 | 48 | bookings.forEach((booking) => { 49 | bookingTablesObj[booking.booking_time.toISOString()] = 50 | booking.tables.reduce((obj, table) => { 51 | return { 52 | ...obj, 53 | [table.table_id]: true, 54 | }; 55 | }, {}); 56 | }); 57 | 58 | const tables = restaurant.tables; 59 | 60 | const searchTimesWithTables = searchTimes.map((searchTime) => { 61 | return { 62 | date: new Date(`${day}T${searchTime}`), 63 | time: searchTime, 64 | tables, 65 | }; 66 | }); 67 | 68 | searchTimesWithTables.forEach((t) => { 69 | t.tables = t.tables.filter((table) => { 70 | if (bookingTablesObj[t.date.toISOString()]) { 71 | if (bookingTablesObj[t.date.toISOString()][table.id]) return false; 72 | } 73 | return true; 74 | }); 75 | }); 76 | 77 | return searchTimesWithTables; 78 | }; 79 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./app/**/*.{js,ts,jsx,tsx}"], 4 | theme: { 5 | extend: {}, 6 | fontSize: { 7 | "2xsm": "10px", 8 | xsm: "12px", 9 | sm: "13px", 10 | reg: "15px", 11 | lg: "18px", 12 | "2xl": "22px", 13 | "3xl": "25px", 14 | "4xl": "32px", 15 | "5xl": "40px", 16 | "6xl": "50px", 17 | "7xl": "70px", 18 | }, 19 | }, 20 | plugins: [], 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "plugins": [ 18 | { 19 | "name": "next" 20 | } 21 | ] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /utils/calculateReviewRatingAverage.ts: -------------------------------------------------------------------------------- 1 | import { Review } from "@prisma/client"; 2 | 3 | export const calculateReviewRatingAverage = (reviews: Review[]) => { 4 | if (!reviews.length) return 0; 5 | 6 | return ( 7 | reviews.reduce((sum, review) => { 8 | return sum + review.rating; 9 | }, 0) / reviews.length 10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /utils/convertToDisplayTime.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | const displayTimeObject = { 4 | "00:00:00.000Z": "12:00 AM", 5 | "00:30:00.000Z": "12:30 AM", 6 | "01:00:00.000Z": "1:00 AM", 7 | "01:30:00.000Z": "1:30 AM", 8 | "02:00:00.000Z": "2:00 AM", 9 | "02:30:00.000Z": "2:30 AM", 10 | "03:00:00.000Z": "3:00 AM", 11 | "03:30:00.000Z": "3:30 AM", 12 | "04:00:00.000Z": "4:00 AM", 13 | "04:30:00.000Z": "4:30 AM", 14 | "05:00:00.000Z": "5:00 AM", 15 | "05:30:00.000Z": "5:30 AM", 16 | "06:00:00.000Z": "6:00 AM", 17 | "06:30:00.000Z": "6:30 AM", 18 | "07:00:00.000Z": "7:00 AM", 19 | "07:30:00.000Z": "7:30 AM", 20 | "08:00:00.000Z": "8:00 AM", 21 | "08:30:00.000Z": "8:30 AM", 22 | "09:00:00.000Z": "9:00 AM", 23 | "09:30:00.000Z": "9:30 AM", 24 | "10:00:00.000Z": "10:00 AM", 25 | "10:30:00.000Z": "10:30 AM", 26 | "11:00:00.000Z": "11:00 AM", 27 | "11:30:00.000Z": "11:30 AM", 28 | "12:00:00.000Z": "12:00 PM", 29 | "12:30:00.000Z": "12:30 PM", 30 | "13:00:00.000Z": "1:00 PM", 31 | "13:30:00.000Z": "1:30 PM", 32 | "14:00:00.000Z": "2:00 PM", 33 | "14:30:00.000Z": "2:30 PM", 34 | "15:00:00.000Z": "3:00 PM", 35 | "15:30:00.000Z": "3:30 PM", 36 | "16:00:00.000Z": "4:00 PM", 37 | "16:30:00.000Z": "4:30 PM", 38 | "17:00:00.000Z": "5:00 PM", 39 | "17:30:00.000Z": "5:30 PM", 40 | "18:00:00.000Z": "6:00 PM", 41 | "18:30:00.000Z": "6:30 PM", 42 | "19:00:00.000Z": "7:00 PM", 43 | "19:30:00.000Z": "7:30 PM", 44 | "20:00:00.000Z": "8:00 PM", 45 | "20:30:00.000Z": "8:30 PM", 46 | "21:00:00.000Z": "9:00 PM", 47 | "21:30:00.000Z": "9:30 PM", 48 | "22:00:00.000Z": "10:00 PM", 49 | "22:30:00.000Z": "10:30 PM", 50 | "23:00:00.000Z": "11:00 PM", 51 | "23:30:00.000Z": "11:30 PM", 52 | }; 53 | 54 | export type Time = keyof typeof displayTimeObject 55 | 56 | export const convertToDisplayTime = (time: Time) => { 57 | return displayTimeObject[time] 58 | } --------------------------------------------------------------------------------