first commit

This commit is contained in:
hamawebdev 2025-02-24 21:08:29 +01:00
commit 358e60d61a
69 changed files with 8848 additions and 0 deletions

1
.env.development Normal file
View file

@ -0,0 +1 @@
VITE_API_URL=""

1
.env.production Normal file
View file

@ -0,0 +1 @@
VITE_API_URL="https://pizza-server-app.onrender.com"

8
.gitignore vendored Normal file
View file

@ -0,0 +1,8 @@
node_modules
dist/
.env
.DS_Store
coverage/
.vscode/
routeTree.gen.ts
coverage

36
eslint.config.mjs Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

48
package.json Normal file
View 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

Binary file not shown.

20
public/padre_gino.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 79 KiB

BIN
public/pizzas/bbq_ckn.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

BIN
public/pizzas/big_meat.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

BIN
public/pizzas/cali_ckn.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 463 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

BIN
public/pizzas/hawaiian.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 573 KiB

BIN
public/pizzas/mexicana.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 542 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 402 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 569 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 454 KiB

BIN
public/pizzas/sicilian.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 476 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 522 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 577 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 KiB

BIN
public/pizzas/thai_ckn.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 484 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 451 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

461
public/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;

View 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();
});

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

View 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);
});

View 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("");
});

View 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>
`;

View 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>
`;

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

View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
import { createContext } from "react";
export const CartContext = createContext([[], () => {}]);

26
src/routes/__root.jsx Normal file
View 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 />
</>
);
},
});

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