first commit
1
.env.development
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=""
|
||||
1
.env.production
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL="https://pizza-server-app.onrender.com"
|
||||
8
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
node_modules
|
||||
dist/
|
||||
.env
|
||||
.DS_Store
|
||||
coverage/
|
||||
.vscode/
|
||||
routeTree.gen.ts
|
||||
coverage
|
||||
36
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
import prettier from "eslint-config-prettier";
|
||||
import reactPlugin from "eslint-plugin-react";
|
||||
import pluginQuery from "@tanstack/eslint-plugin-query";
|
||||
|
||||
/** @type {import('eslint').Linter.Config[]} */
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
...reactPlugin.configs.flat.recommended,
|
||||
settings: {
|
||||
react: {
|
||||
version: "detect",
|
||||
},
|
||||
},
|
||||
},
|
||||
reactPlugin.configs.flat["jsx-runtime"],
|
||||
...pluginQuery.configs["flat/recommended"],
|
||||
{
|
||||
files: ["**/*.js", "**/*.jsx"],
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node },
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"react/no-unescaped-entities": "off",
|
||||
"react/prop-types": "off",
|
||||
},
|
||||
},
|
||||
prettier,
|
||||
];
|
||||
16
index.html
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<title>Padre Gino's</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="modal"></div>
|
||||
<div id="root">not rendered</div>
|
||||
<script type="module" src="./src/App.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
7199
package-lock.json
generated
Normal file
48
package.json
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"name": "citr-v9-projects",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"coverage": "vitest --coverage",
|
||||
"build": "vite build",
|
||||
"dev": "vite",
|
||||
"format": "prettier --write \"src/**/*.{js,jsx}\"",
|
||||
"lint": "eslint",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:ui": "vitest --ui"
|
||||
},
|
||||
"keywords": [],
|
||||
"type": "module",
|
||||
"author": "Brian Holt",
|
||||
"license": "Apache-2.0",
|
||||
"description": "The Complete Intro to React v9, as taught by Brian Holt for Frontend Masters",
|
||||
"devDependencies": {
|
||||
"@tanstack/eslint-plugin-query": "5.59.7",
|
||||
"@tanstack/router-devtools": "1.65.0",
|
||||
"@tanstack/router-plugin": "1.65.0",
|
||||
"@testing-library/react": "16.0.1",
|
||||
"@vitejs/plugin-react": "4.3.1",
|
||||
"@vitest/browser": "2.1.3",
|
||||
"@vitest/coverage-v8": "^2.1.3",
|
||||
"@vitest/ui": "^2.1.3",
|
||||
"eslint": "9.9.1",
|
||||
"eslint-config-prettier": "9.1.0",
|
||||
"eslint-plugin-react": "7.37.1",
|
||||
"globals": "15.9.0",
|
||||
"happy-dom": "15.7.4",
|
||||
"playwright": "1.48.0",
|
||||
"prettier": "3.3.3",
|
||||
"vite": "5.4.2",
|
||||
"vitest": "2.1.3",
|
||||
"vitest-browser-react": "0.0.1",
|
||||
"vitest-fetch-mock": "0.3.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.59.13",
|
||||
"@tanstack/react-query-devtools": "5.59.13",
|
||||
"@tanstack/react-router": "1.65.0",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
}
|
||||
}
|
||||
BIN
public/Pacifico-Regular.ttf
Normal file
20
public/padre_gino.svg
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
public/pizzas/bbq_ckn.webp
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
public/pizzas/big_meat.webp
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
public/pizzas/brie_carre.webp
Normal file
|
After Width: | Height: | Size: 544 KiB |
BIN
public/pizzas/calabrese.webp
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
public/pizzas/cali_ckn.webp
Normal file
|
After Width: | Height: | Size: 463 KiB |
BIN
public/pizzas/ckn_alfredo.webp
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
public/pizzas/ckn_pesto.webp
Normal file
|
After Width: | Height: | Size: 555 KiB |
BIN
public/pizzas/classic_dlx.webp
Normal file
|
After Width: | Height: | Size: 492 KiB |
BIN
public/pizzas/five_cheese.webp
Normal file
|
After Width: | Height: | Size: 504 KiB |
BIN
public/pizzas/four_cheese.webp
Normal file
|
After Width: | Height: | Size: 485 KiB |
BIN
public/pizzas/green_garden.webp
Normal file
|
After Width: | Height: | Size: 529 KiB |
BIN
public/pizzas/hawaiian.webp
Normal file
|
After Width: | Height: | Size: 345 KiB |
BIN
public/pizzas/ital_cpcllo.webp
Normal file
|
After Width: | Height: | Size: 561 KiB |
BIN
public/pizzas/ital_supr.webp
Normal file
|
After Width: | Height: | Size: 448 KiB |
BIN
public/pizzas/ital_veggie.webp
Normal file
|
After Width: | Height: | Size: 552 KiB |
BIN
public/pizzas/mediterraneo.webp
Normal file
|
After Width: | Height: | Size: 573 KiB |
BIN
public/pizzas/mexicana.webp
Normal file
|
After Width: | Height: | Size: 542 KiB |
BIN
public/pizzas/napolitana.webp
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
public/pizzas/pep_msh_pep.webp
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
public/pizzas/pepperoni.webp
Normal file
|
After Width: | Height: | Size: 402 KiB |
BIN
public/pizzas/peppr_salami.webp
Normal file
|
After Width: | Height: | Size: 569 KiB |
BIN
public/pizzas/prsc_argla.webp
Normal file
|
After Width: | Height: | Size: 454 KiB |
BIN
public/pizzas/sicilian.webp
Normal file
|
After Width: | Height: | Size: 476 KiB |
BIN
public/pizzas/soppressata.webp
Normal file
|
After Width: | Height: | Size: 530 KiB |
BIN
public/pizzas/southw_ckn.webp
Normal file
|
After Width: | Height: | Size: 522 KiB |
BIN
public/pizzas/spicy_ital.webp
Normal file
|
After Width: | Height: | Size: 525 KiB |
BIN
public/pizzas/spin_pesto.webp
Normal file
|
After Width: | Height: | Size: 577 KiB |
BIN
public/pizzas/spinach_fet.webp
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
public/pizzas/spinach_supr.webp
Normal file
|
After Width: | Height: | Size: 470 KiB |
BIN
public/pizzas/thai_ckn.webp
Normal file
|
After Width: | Height: | Size: 484 KiB |
BIN
public/pizzas/the_greek.webp
Normal file
|
After Width: | Height: | Size: 451 KiB |
BIN
public/pizzas/veggie_veg.webp
Normal file
|
After Width: | Height: | Size: 508 KiB |
461
public/style.css
Normal file
|
|
@ -0,0 +1,461 @@
|
|||
:root {
|
||||
--primary: #da2f04;
|
||||
--secondary: #33670a;
|
||||
--background: #fffaee;
|
||||
--border: #ccc;
|
||||
--font: Pacifico, cursive, sans-serif;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: var(--background);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
nav {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
border-bottom: 1px solid #ccc;
|
||||
grid-template-areas: ". logo logo logo cart";
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 100%;
|
||||
height: 110px;
|
||||
background-position: left;
|
||||
background-repeat: no-repeat;
|
||||
content: url("/padre_gino.svg");
|
||||
border-bottom: 1px solid #ccc;
|
||||
padding-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
nav .logo {
|
||||
width: inherit;
|
||||
grid-area: logo;
|
||||
}
|
||||
|
||||
nav > a {
|
||||
grid-area: logo;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.nav-cart {
|
||||
grid-area: cart;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.nav-cart-number {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
top: -17px;
|
||||
left: -17px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 18px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Pacifico;
|
||||
src: url("/Pacifico-Regular.ttf");
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-family: var(--font);
|
||||
font-size: 40px;
|
||||
line-height: auto;
|
||||
font-weight: 400;
|
||||
color: var(--primary);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
h2::after {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
margin-left: 25px;
|
||||
width: 150px;
|
||||
height: 30px;
|
||||
background: conic-gradient(
|
||||
transparent 90deg,
|
||||
var(--primary) 90deg 180deg,
|
||||
transparent 180deg 270deg,
|
||||
var(--primary) 270deg
|
||||
);
|
||||
background-repeat: repeat;
|
||||
background-size: 30px 30px;
|
||||
background-position: top left;
|
||||
}
|
||||
|
||||
.order {
|
||||
width: 100%;
|
||||
margin-left: 5%;
|
||||
}
|
||||
|
||||
form div {
|
||||
margin: 10px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
form > div {
|
||||
padding: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form > div:first-child {
|
||||
border-right: 1px solid #ccc;
|
||||
}
|
||||
|
||||
form > div > div > label {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
form select {
|
||||
display: block;
|
||||
font-size: 16px;
|
||||
padding: 5px;
|
||||
margin-bottom: 30px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
form input[type="radio"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
form input + label {
|
||||
height: 80px;
|
||||
width: 80px;
|
||||
border: 1px solid #999;
|
||||
background-color: #ccc;
|
||||
color: #999;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
margin: 0 15px 10px 15px;
|
||||
}
|
||||
|
||||
form input:checked + label {
|
||||
color: #333;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
form > div > div > label {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
color: var(--secondary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.pizza {
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.pizza img {
|
||||
max-width: 200px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.pizza h1 {
|
||||
font-weight: normal;
|
||||
color: var(--secondary);
|
||||
font-size: 25px;
|
||||
}
|
||||
|
||||
.pizza p {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.order form {
|
||||
display: flex;
|
||||
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
button,
|
||||
.index li a {
|
||||
border: 1px solid var(--primary);
|
||||
background-color: transparent;
|
||||
color: var(--primary);
|
||||
font-family: var(--font);
|
||||
font-size: 20px;
|
||||
padding: 5px 15px;
|
||||
border-radius: 5px;
|
||||
display: inline-block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.order-pizza {
|
||||
width: 100%;
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
.pizza {
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pizza-of-the-day {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 50px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.pizza-of-the-day > div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pizza-of-the-day > h2 {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pizza-of-the-day-image {
|
||||
max-width: 200px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.pizza-of-the-day-info {
|
||||
margin-right: 30px;
|
||||
line-height: 2;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.order-page {
|
||||
max-width: 1300px;
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 50px;
|
||||
}
|
||||
|
||||
.cart {
|
||||
border-left: 1px solid var(--border);
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.cart p {
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.index h1 {
|
||||
color: var(--primary);
|
||||
font-family: var(--font);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.index {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 30px;
|
||||
max-width: 700px;
|
||||
margin: 120px auto;
|
||||
}
|
||||
|
||||
.index-brand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.index-brand p {
|
||||
color: var(--secondary);
|
||||
font-weight: bold;
|
||||
font-size: 40px;
|
||||
text-transform: uppercase;
|
||||
max-width: 315px;
|
||||
}
|
||||
|
||||
.index ul {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.index li,
|
||||
.index li a {
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.index li a {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.past-orders {
|
||||
min-height: 650px;
|
||||
max-width: 900px;
|
||||
width: 90%;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 25px 0;
|
||||
font-size: 0.9em;
|
||||
font-family: sans-serif;
|
||||
min-width: 400px;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
thead tr {
|
||||
background-color: var(--secondary);
|
||||
color: #ffffff;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 12px 15px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tbody tr {
|
||||
border-bottom: 1px solid #dddddd;
|
||||
}
|
||||
|
||||
tbody tr:nth-of-type(even) {
|
||||
background-color: #f6fef0;
|
||||
}
|
||||
|
||||
tbody tr:last-of-type {
|
||||
border-bottom: 2px solid var(--secondary);
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-evenly;
|
||||
}
|
||||
|
||||
.pages > div {
|
||||
font-family: var(--font);
|
||||
color: var(--primary);
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#modal {
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#modal:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#modal > div {
|
||||
padding: 15px;
|
||||
text-align: center;
|
||||
border-radius: 30px;
|
||||
background: var(--background);
|
||||
}
|
||||
td img {
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.error-boundary {
|
||||
min-height: 400px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.error-boundary a {
|
||||
color: var(--primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.contact form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.contact input,
|
||||
.contact textarea {
|
||||
width: 500px;
|
||||
padding: 8px;
|
||||
border: 2px solid var(--border);
|
||||
border-radius: 5px;
|
||||
margin-bottom: 15px;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.contact textarea {
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.contact input:focus,
|
||||
.contact textarea:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.contact input:disabled {
|
||||
background-color: #999;
|
||||
}
|
||||
|
||||
.contact h3 {
|
||||
font-family: var(--font);
|
||||
color: var(--secondary);
|
||||
text-align: center;
|
||||
margin: 50px;
|
||||
font-weight: normal;
|
||||
font-size: 30px;
|
||||
}
|
||||
22
src/App.jsx
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({ routeTree });
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</StrictMode>
|
||||
);
|
||||
};
|
||||
|
||||
const container = document.getElementById("root");
|
||||
const root = createRoot(container);
|
||||
root.render(<App />);
|
||||
28
src/Cart.jsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const intl = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD", // feel free to change to your local currency
|
||||
});
|
||||
|
||||
export default function Cart({ cart, checkout }) {
|
||||
let total = 0;
|
||||
for (let i = 0; i < cart.length; i++) {
|
||||
const current = cart[i];
|
||||
total += current.pizza.sizes[current.size];
|
||||
}
|
||||
return (
|
||||
<div className="cart">
|
||||
<h2>Cart</h2>
|
||||
<ul>
|
||||
{cart.map((item, index) => (
|
||||
<li key={index}>
|
||||
<span className="size">{item.size}</span> –
|
||||
<span className="type">{item.pizza.name}</span> –
|
||||
<span className="price">{item.price}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>Total: {intl.format(total)}</p>
|
||||
<button onClick={checkout}>Checkout</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/ErrorBoundary.jsx
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// mostly code from reactjs.org/docs/error-boundaries.html
|
||||
import { Component } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
state = { hasError: false };
|
||||
static getDerivedStateFromError() {
|
||||
return { hasError: true };
|
||||
}
|
||||
componentDidCatch(error, info) {
|
||||
console.error("ErrorBoundary caught an error", error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return (
|
||||
<div className="error-boundary">
|
||||
<h2>Uh oh!</h2>
|
||||
<p>
|
||||
There was an error with this listing. <Link to="/">Click here</Link>{" "}
|
||||
to back to the home page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
20
src/Header.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useContext } from "react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import { CartContext } from "./contexts";
|
||||
|
||||
export default function Header() {
|
||||
const [cart] = useContext(CartContext);
|
||||
return (
|
||||
<nav>
|
||||
<Link to={"/"}>
|
||||
<h1 className="logo">Padre Gino's Pizza</h1>
|
||||
</Link>
|
||||
<div className="nav-cart">
|
||||
🛒
|
||||
<span data-testid="cart-number" className="nav-cart-number">
|
||||
{cart.length}
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
19
src/Modal.jsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Modal = ({ children }) => {
|
||||
const elRef = useRef(null);
|
||||
if (!elRef.current) {
|
||||
elRef.current = document.createElement("div");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const modalRoot = document.getElementById("modal");
|
||||
modalRoot.appendChild(elRef.current);
|
||||
return () => modalRoot.removeChild(elRef.current);
|
||||
}, []);
|
||||
|
||||
return createPortal(<div>{children}</div>, elRef.current);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
14
src/Pizza.jsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const Pizza = (props) => {
|
||||
return (
|
||||
<div className="pizza">
|
||||
<h1>{props.name}</h1>
|
||||
<p>{props.description}</p>
|
||||
<img
|
||||
src={props.image ? props.image : "https://picsum.photos/200"}
|
||||
alt={props.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pizza;
|
||||
37
src/PizzaOfTheDay.jsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { usePizzaOfTheDay } from "./usePizzaOfTheDay";
|
||||
|
||||
// feel free to change en-US / USD to your locale
|
||||
const intl = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
const PizzaOfTheDay = () => {
|
||||
const pizzaOfTheDay = usePizzaOfTheDay();
|
||||
|
||||
if (!pizzaOfTheDay) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pizza-of-the-day">
|
||||
<h2>Pizza of the Day</h2>
|
||||
<div>
|
||||
<div className="pizza-of-the-day-info">
|
||||
<h3>{pizzaOfTheDay.name}</h3>
|
||||
<p>{pizzaOfTheDay.description}</p>
|
||||
<p className="pizza-of-the-day-price">
|
||||
From: <span>{intl.format(pizzaOfTheDay.sizes.S)}</span>
|
||||
</p>
|
||||
</div>
|
||||
<img
|
||||
className="pizza-of-the-day-image"
|
||||
src={pizzaOfTheDay.image}
|
||||
alt={pizzaOfTheDay.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PizzaOfTheDay;
|
||||
68
src/__tests__/Cart.browser.test.jsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { expect, test } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import Cart from "../Cart";
|
||||
|
||||
test("snapshot with nothing in cart", () => {
|
||||
const { asFragment } = render(<Cart cart={[]} />);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test("snapshot with some stuff in cart", () => {
|
||||
const { asFragment } = render(
|
||||
<Cart
|
||||
cart={[
|
||||
{
|
||||
pizza: {
|
||||
id: "pepperoni",
|
||||
name: "The Pepperoni Pizza",
|
||||
category: "Classic",
|
||||
description: "Mozzarella Cheese, Pepperoni",
|
||||
image: "/public/pizzas/pepperoni.webp",
|
||||
sizes: {
|
||||
S: 9.75,
|
||||
M: 12.5,
|
||||
L: 15.25,
|
||||
},
|
||||
},
|
||||
size: "M",
|
||||
price: "$12.50",
|
||||
},
|
||||
{
|
||||
pizza: {
|
||||
id: "ckn_pesto",
|
||||
name: "The Chicken Pesto Pizza",
|
||||
category: "Chicken",
|
||||
description:
|
||||
"Chicken, Tomatoes, Red Peppers, Spinach, Garlic, Pesto Sauce",
|
||||
image: "/public/pizzas/ckn_pesto.webp",
|
||||
sizes: {
|
||||
S: 12.75,
|
||||
M: 16.75,
|
||||
L: 20.75,
|
||||
},
|
||||
},
|
||||
size: "L",
|
||||
price: "$20.75",
|
||||
},
|
||||
{
|
||||
pizza: {
|
||||
id: "bbq_ckn",
|
||||
name: "The Barbecue Chicken Pizza",
|
||||
category: "Chicken",
|
||||
description:
|
||||
"Barbecued Chicken, Red Peppers, Green Peppers, Tomatoes, Red Onions, Barbecue Sauce",
|
||||
image: "/public/pizzas/bbq_ckn.webp",
|
||||
sizes: {
|
||||
S: 12.75,
|
||||
M: 16.75,
|
||||
L: 20.75,
|
||||
},
|
||||
},
|
||||
size: "S",
|
||||
price: "$12.75",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
47
src/__tests__/Header.browser.test.jsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import { render } from "vitest-browser-react";
|
||||
import { expect, test } from "vitest";
|
||||
import Header from "../Header";
|
||||
import {
|
||||
RouterProvider,
|
||||
createRouter,
|
||||
createRootRoute,
|
||||
} from "@tanstack/react-router";
|
||||
import { CartContext } from "../contexts";
|
||||
|
||||
test("correctly renders a header with a zero cart count", async () => {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<CartContext.Provider value={[[]]}>
|
||||
<Header />
|
||||
</CartContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
const router = createRouter({ routeTree: rootRoute });
|
||||
const screen = render(<RouterProvider router={router}></RouterProvider>);
|
||||
|
||||
const itemsInCart = await screen.getByTestId("cart-number");
|
||||
|
||||
await expect.element(itemsInCart).toBeInTheDocument();
|
||||
await expect.element(itemsInCart).toHaveTextContent("0");
|
||||
});
|
||||
|
||||
test("correctly renders a header with a three cart count", async () => {
|
||||
const rootRoute = createRootRoute({
|
||||
component: () => (
|
||||
<CartContext.Provider
|
||||
value={[[{ pizza: 1 }, { pizza: 2 }, { pizza: 3 }]]}
|
||||
>
|
||||
<Header />
|
||||
</CartContext.Provider>
|
||||
),
|
||||
});
|
||||
|
||||
const router = createRouter({ routeTree: rootRoute });
|
||||
const screen = render(<RouterProvider router={router}></RouterProvider>);
|
||||
|
||||
const itemsInCart = await screen.getByTestId("cart-number");
|
||||
|
||||
await expect.element(itemsInCart).toBeInTheDocument();
|
||||
await expect.element(itemsInCart).toHaveTextContent("3");
|
||||
});
|
||||
17
src/__tests__/Pizza.browser.test.jsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { render } from "vitest-browser-react";
|
||||
import { expect, test } from "vitest";
|
||||
import Pizza from "../Pizza";
|
||||
|
||||
test("alt text renders on image", async () => {
|
||||
const name = "My Favorite Pizza";
|
||||
const src = "https://picsum.photos/200";
|
||||
const screen = render(
|
||||
<Pizza name={name} description="super cool pizza" image={src} />,
|
||||
);
|
||||
|
||||
const img = await screen.getByRole("img");
|
||||
|
||||
await expect.element(img).toBeInTheDocument();
|
||||
await expect.element(img).toHaveAttribute("src", src);
|
||||
await expect.element(img).toHaveAttribute("alt", name);
|
||||
});
|
||||
26
src/__tests__/Pizza.node.test.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { render, cleanup } from "@testing-library/react";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import Pizza from "../Pizza";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
test("alt text renders on image", async () => {
|
||||
const name = "My Favorite Pizza";
|
||||
const src = "https://picsum.photos/200";
|
||||
const screen = render(
|
||||
<Pizza name={name} description="super cool pizza" image={src} />,
|
||||
);
|
||||
|
||||
const img = screen.getByRole("img");
|
||||
expect(img.src).toBe(src);
|
||||
expect(img.alt).toBe(name);
|
||||
});
|
||||
|
||||
test("to have default image if none is provided", async () => {
|
||||
const screen = render(
|
||||
<Pizza name={"Cool Pizza"} description="super cool pizza" />,
|
||||
);
|
||||
|
||||
const img = screen.getByRole("img");
|
||||
expect(img.src).not.toBe("");
|
||||
});
|
||||
97
src/__tests__/__snapshots__/Cart.browser.test.jsx.snap
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`snapshot with nothing in cart 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="cart"
|
||||
>
|
||||
<h2>
|
||||
Cart
|
||||
</h2>
|
||||
<ul />
|
||||
<p>
|
||||
Total: $0.00
|
||||
</p>
|
||||
<button>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`snapshot with some stuff in cart 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="cart"
|
||||
>
|
||||
<h2>
|
||||
Cart
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
M
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Pepperoni Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$12.50
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
L
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Chicken Pesto Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$20.75
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Barbecue Chicken Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$12.75
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Total: $46.00
|
||||
</p>
|
||||
<button>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
97
src/__tests__/__snapshots__/Cart.test.jsx.snap
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`snapshot with nothing in cart 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="cart"
|
||||
>
|
||||
<h2>
|
||||
Cart
|
||||
</h2>
|
||||
<ul />
|
||||
<p>
|
||||
Total: $0.00
|
||||
</p>
|
||||
<button>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`snapshot with some stuff in cart 1`] = `
|
||||
<DocumentFragment>
|
||||
<div
|
||||
class="cart"
|
||||
>
|
||||
<h2>
|
||||
Cart
|
||||
</h2>
|
||||
<ul>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
M
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Pepperoni Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$12.50
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
L
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Chicken Pesto Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$20.75
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span
|
||||
class="size"
|
||||
>
|
||||
S
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="type"
|
||||
>
|
||||
The Barbecue Chicken Pizza
|
||||
</span>
|
||||
–
|
||||
<span
|
||||
class="price"
|
||||
>
|
||||
$12.75
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Total: $46.00
|
||||
</p>
|
||||
<button>
|
||||
Checkout
|
||||
</button>
|
||||
</div>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
52
src/__tests__/contact.lazy.node.test.jsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { render } from "@testing-library/react";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import createFetchMock from "vitest-fetch-mock";
|
||||
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
|
||||
import { Route } from "../routes/contact.lazy";
|
||||
|
||||
const queryClient = new QueryClient({});
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
fetchMocker.enableMocks();
|
||||
|
||||
test("can submit contact form", async () => {
|
||||
fetchMocker.mockResponse(JSON.stringify({ status: "ok" }));
|
||||
const screen = render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Route.options.component />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
const nameInput = screen.getByPlaceholderText("Name");
|
||||
const emailInput = screen.getByPlaceholderText("Email");
|
||||
const msgTextArea = screen.getByPlaceholderText("Message");
|
||||
|
||||
const testData = {
|
||||
name: "Brian",
|
||||
email: "test@example.com",
|
||||
message: "This is a test message",
|
||||
};
|
||||
|
||||
nameInput.value = testData.name;
|
||||
emailInput.value = testData.email;
|
||||
msgTextArea.value = testData.message;
|
||||
|
||||
const btn = screen.getByRole("button");
|
||||
|
||||
btn.click();
|
||||
|
||||
const h3 = await screen.findByRole("heading", { level: 3 });
|
||||
|
||||
expect(h3.innerText).toContain("Submitted");
|
||||
|
||||
const requests = fetchMocker.requests();
|
||||
expect(requests.length).toBe(1);
|
||||
expect(requests[0].url).toBe("/api/contact");
|
||||
expect(fetchMocker).toHaveBeenCalledWith("/api/contact", {
|
||||
body: JSON.stringify(testData),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
32
src/__tests__/usePizzaOfTheDay.node.test.jsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { expect, test, vi } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import createFetchMock from "vitest-fetch-mock";
|
||||
import { usePizzaOfTheDay } from "../usePizzaOfTheDay";
|
||||
|
||||
const fetchMocker = createFetchMock(vi);
|
||||
fetchMocker.enableMocks();
|
||||
|
||||
const testPizza = {
|
||||
id: "calabrese",
|
||||
name: "The Calabrese Pizza",
|
||||
category: "Supreme",
|
||||
description:
|
||||
"Salami, Pancetta, Tomatoes, Red Onions, Friggitello Peppers, Garlic",
|
||||
image: "/public/pizzas/calabrese.webp",
|
||||
sizes: { S: 12.25, M: 16.25, L: 20.25 },
|
||||
};
|
||||
|
||||
test("to be null on initial load", async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify(testPizza));
|
||||
const { result } = renderHook(() => usePizzaOfTheDay(""));
|
||||
expect(result.current).toBeNull();
|
||||
});
|
||||
|
||||
test("to call the API and give back the pizza of the day", async () => {
|
||||
fetch.mockResponseOnce(JSON.stringify(testPizza));
|
||||
const { result } = renderHook(() => usePizzaOfTheDay(""));
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual(testPizza);
|
||||
});
|
||||
expect(fetchMocker).toBeCalledWith("/api/pizza-of-the-day");
|
||||
});
|
||||
6
src/api/getPastOrder.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default async function getPastOrders(order) {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const response = await fetch(`${apiUrl}/api/past-order/${order}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
6
src/api/getPastOrders.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default async function getPastOrders(page) {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const response = await fetch(`${apiUrl}/api/past-orders?page=${page}`);
|
||||
const data = await response.json();
|
||||
return data;
|
||||
}
|
||||
16
src/api/postContact.js
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
export default async function postContact(name, email, message) {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const response = await fetch(`${apiUrl}/api/contact`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ name, email, message }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error("Network response was not ok");
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
3
src/contexts.jsx
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
import { createContext } from "react";
|
||||
|
||||
export const CartContext = createContext([[], () => {}]);
|
||||
26
src/routes/__root.jsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { useState } from "react";
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools";
|
||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||
import PizzaOfTheDay from "../PizzaOfTheDay";
|
||||
import Header from "../Header";
|
||||
import { CartContext } from "../contexts";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => {
|
||||
const cartHook = useState([]);
|
||||
return (
|
||||
<>
|
||||
<CartContext.Provider value={cartHook}>
|
||||
<div>
|
||||
<Header />
|
||||
<Outlet />
|
||||
<PizzaOfTheDay />
|
||||
</div>
|
||||
</CartContext.Provider>
|
||||
<TanStackRouterDevtools />
|
||||
<ReactQueryDevtools />
|
||||
</>
|
||||
);
|
||||
},
|
||||
});
|
||||
37
src/routes/contact.lazy.jsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import postContact from "../api/postContact";
|
||||
|
||||
export const Route = createLazyFileRoute("/contact")({
|
||||
component: ContactRoute,
|
||||
});
|
||||
|
||||
function ContactRoute() {
|
||||
const mutation = useMutation({
|
||||
mutationFn: function (e) {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
return postContact(
|
||||
formData.get("name"),
|
||||
formData.get("email"),
|
||||
formData.get("message"),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="contact">
|
||||
<h2>Contact</h2>
|
||||
{mutation.isSuccess ? (
|
||||
<h3>Submitted!</h3>
|
||||
) : (
|
||||
<form onSubmit={mutation.mutate}>
|
||||
<input name="name" placeholder="Name" />
|
||||
<input type="email" name="email" placeholder="Email" />
|
||||
<textarea placeholder="Message" name="message"></textarea>
|
||||
<button>Submit</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/routes/index.lazy.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createLazyFileRoute, Link } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createLazyFileRoute("/")({
|
||||
component: Index,
|
||||
});
|
||||
|
||||
function Index() {
|
||||
return (
|
||||
<div className="index">
|
||||
<div className="index-brand">
|
||||
<h1>Padre Gino's</h1>
|
||||
<p>Pizza & Art at a location near you</p>
|
||||
</div>
|
||||
<ul>
|
||||
<li>
|
||||
<Link to="/order">Order</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/past">Past Orders</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link to="/contact">Contact</Link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
src/routes/order.lazy.jsx
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { useState, useEffect, useContext } from "react";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import { CartContext } from "../contexts";
|
||||
import Cart from "../Cart";
|
||||
import Pizza from "../Pizza";
|
||||
|
||||
// feel free to change en-US / USD to your locale
|
||||
const intl = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
export const Route = createLazyFileRoute("/order")({
|
||||
component: Order,
|
||||
});
|
||||
|
||||
function Order() {
|
||||
const [pizzaType, setPizzaType] = useState("pepperoni");
|
||||
const [pizzaSize, setPizzaSize] = useState("M");
|
||||
const [pizzaTypes, setPizzaTypes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [cart, setCart] = useContext(CartContext);
|
||||
|
||||
async function checkout() {
|
||||
setLoading(true);
|
||||
|
||||
await fetch(`${apiUrl}/api/order`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
cart,
|
||||
}),
|
||||
});
|
||||
|
||||
setCart([]);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
let price, selectedPizza;
|
||||
if (!loading) {
|
||||
selectedPizza = pizzaTypes.find((pizza) => pizzaType === pizza.id);
|
||||
price = intl.format(
|
||||
selectedPizza.sizes ? selectedPizza.sizes[pizzaSize] : "",
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPizzaTypes();
|
||||
}, []);
|
||||
|
||||
async function fetchPizzaTypes() {
|
||||
const pizzasRes = await fetch(`${apiUrl}/api/pizzas`);
|
||||
const pizzasJson = await pizzasRes.json();
|
||||
setPizzaTypes(pizzasJson);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="order-page">
|
||||
<div className="order">
|
||||
<h2>Create Order</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setCart([
|
||||
...cart,
|
||||
{ pizza: selectedPizza, size: pizzaSize, price },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<label htmlFor="pizza-type">Pizza Type</label>
|
||||
<select
|
||||
onChange={(e) => setPizzaType(e.target.value)}
|
||||
name="pizza-type"
|
||||
value={pizzaType}
|
||||
>
|
||||
{pizzaTypes.map((pizza) => (
|
||||
<option key={pizza.id} value={pizza.id}>
|
||||
{pizza.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="pizza-size">Pizza Size</label>
|
||||
<div>
|
||||
<span>
|
||||
<input
|
||||
onChange={(e) => setPizzaSize(e.target.value)}
|
||||
checked={pizzaSize === "S"}
|
||||
type="radio"
|
||||
name="pizza-size"
|
||||
value="S"
|
||||
id="pizza-s"
|
||||
/>
|
||||
<label htmlFor="pizza-s">Small</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
onChange={(e) => setPizzaSize(e.target.value)}
|
||||
checked={pizzaSize === "M"}
|
||||
type="radio"
|
||||
name="pizza-size"
|
||||
value="M"
|
||||
id="pizza-m"
|
||||
/>
|
||||
<label htmlFor="pizza-m">Medium</label>
|
||||
</span>
|
||||
<span>
|
||||
<input
|
||||
onChange={(e) => setPizzaSize(e.target.value)}
|
||||
checked={pizzaSize === "L"}
|
||||
type="radio"
|
||||
name="pizza-size"
|
||||
value="L"
|
||||
id="pizza-l"
|
||||
/>
|
||||
<label htmlFor="pizza-l">Large</label>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit">Add to Cart</button>
|
||||
</div>
|
||||
{loading ? (
|
||||
<h3>LOADING …</h3>
|
||||
) : (
|
||||
<div className="order-pizza">
|
||||
<Pizza
|
||||
name={selectedPizza.name}
|
||||
description={selectedPizza.description}
|
||||
image={selectedPizza.image}
|
||||
/>
|
||||
<p>{price}</p>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
{loading ? <h2>LOADING …</h2> : <Cart checkout={checkout} cart={cart} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
120
src/routes/past.lazy.jsx
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||
import getPastOrders from "../api/getPastOrders";
|
||||
import getPastOrder from "../api/getPastOrder";
|
||||
import Modal from "../Modal";
|
||||
import ErrorBoundary from "../ErrorBoundary";
|
||||
|
||||
export const Route = createLazyFileRoute("/past")({
|
||||
component: ErrorBoundaryWrappedPastOrderRoutes,
|
||||
});
|
||||
|
||||
const intl = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
});
|
||||
|
||||
function ErrorBoundaryWrappedPastOrderRoutes() {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<PastOrdersRoute />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
function PastOrdersRoute() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [focusedOrder, setFocusedOrder] = useState();
|
||||
const { isLoading, data } = useQuery({
|
||||
queryKey: ["past-orders", page],
|
||||
queryFn: () => getPastOrders(page),
|
||||
staleTime: 30000,
|
||||
});
|
||||
|
||||
const { isLoading: isLoadingPastOrder, data: pastOrderData } = useQuery({
|
||||
queryKey: ["past-order", focusedOrder],
|
||||
queryFn: () => getPastOrder(focusedOrder),
|
||||
enabled: !!focusedOrder,
|
||||
staleTime: 24 * 60 * 60 * 1000, // one day in milliseconds,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="past-orders">
|
||||
<h2>LOADING …</h2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="past-orders">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>ID</td>
|
||||
<td>Date</td>
|
||||
<td>Time</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.map((order) => (
|
||||
<tr key={order.order_id}>
|
||||
<td>
|
||||
<button onClick={() => setFocusedOrder(order.order_id)}>
|
||||
{order.order_id}
|
||||
</button>
|
||||
</td>
|
||||
<td>{order.date}</td>
|
||||
<td>{order.time}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="pages">
|
||||
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
Previous
|
||||
</button>
|
||||
<div>{page}</div>
|
||||
<button disabled={data.length < 10} onClick={() => setPage(page + 1)}>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{focusedOrder ? (
|
||||
<Modal>
|
||||
<h2>Order #{focusedOrder}</h2>
|
||||
{!isLoadingPastOrder ? (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>Image</td>
|
||||
<td>Name</td>
|
||||
<td>Size</td>
|
||||
<td>Quantity</td>
|
||||
<td>Price</td>
|
||||
<td>Total</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pastOrderData.orderItems.map((pizza) => (
|
||||
<tr key={`${pizza.pizzaTypeId}_${pizza.size}`}>
|
||||
<td>
|
||||
<img src={pizza.image} alt={pizza.name} />
|
||||
</td>
|
||||
<td>{pizza.name}</td>
|
||||
<td>{pizza.size}</td>
|
||||
<td>{pizza.quantity}</td>
|
||||
<td>{intl.format(pizza.price)}</td>
|
||||
<td>{intl.format(pizza.total)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
) : (
|
||||
<p>Loading …</p>
|
||||
)}
|
||||
<button onClick={() => setFocusedOrder()}>Close</button>
|
||||
</Modal>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
20
src/usePizzaOfTheDay.jsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { useState, useEffect, useDebugValue } from "react";
|
||||
|
||||
export const usePizzaOfTheDay = () => {
|
||||
const [pizzaOfTheDay, setPizzaOfTheDay] = useState(null);
|
||||
|
||||
useDebugValue(pizzaOfTheDay ? `${pizzaOfTheDay.name}` : "Loading...");
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchPizzaOfTheDay() {
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
const response = await fetch(`${apiUrl}/api/pizza-of-the-day`);
|
||||
const data = await response.json();
|
||||
setPizzaOfTheDay(data);
|
||||
}
|
||||
|
||||
fetchPizzaOfTheDay();
|
||||
}, []);
|
||||
|
||||
return pizzaOfTheDay;
|
||||
};
|
||||
19
vite.config.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
|
||||
export default defineConfig({
|
||||
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://localhost:3000/",
|
||||
changeOrigin: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
plugins: [TanStackRouterVite(), react()]
|
||||
});
|
||||
|
||||
|
||||
|
||||
25
vitest.workspace.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { defineWorkspace } from "vitest/config";
|
||||
|
||||
export default defineWorkspace([
|
||||
{
|
||||
extends: "./vite.config.js",
|
||||
test: {
|
||||
include: ["**/*.node.test.{js,jsx}"],
|
||||
name: "happy-dom",
|
||||
environment: "happy-dom",
|
||||
},
|
||||
},
|
||||
{
|
||||
extends: "./vite.config.js",
|
||||
test: {
|
||||
setupFiles: ["vitest-browser-react"],
|
||||
include: ["**/*.browser.test.{js,jsx}"],
|
||||
name: "browser",
|
||||
browser: {
|
||||
provider: "playwright",
|
||||
enabled: true,
|
||||
name: "firefox",
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||