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",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||