functional version

This commit is contained in:
ak 2023-08-19 17:13:44 -07:00
parent f1bb02c5e7
commit eb9878c7f8
23 changed files with 6373 additions and 0 deletions

20
.eslintrc.cjs Normal file
View file

@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Dee's Fish House</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/components/main.jsx"></script>
</body>
</html>

5540
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

39
package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "react-shopping-cart",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@fortawesome/fontawesome-free": "^6.4.2",
"@fortawesome/free-solid-svg-icons": "^6.4.2",
"@fortawesome/react-fontawesome": "^0.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-responsive": "^9.0.2",
"react-router-dom": "^6.15.0"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.0.0",
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "^14.4.3",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@vitejs/plugin-react": "^4.0.3",
"bootstrap": "^5.3.1",
"eslint": "^8.45.0",
"eslint-plugin-react": "^7.32.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"jsdom": "^22.1.0",
"react-bootstrap": "^2.8.0",
"sass": "^1.65.1",
"vitest": "^0.34.1"
}
}

BIN
panfrying.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src/assets/fishies.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
src/assets/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

BIN
src/assets/panfrying.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 MiB

BIN
src/assets/waves.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

View file

@ -0,0 +1,12 @@
import { Link } from "react-router-dom";
const ErrorPage = () => {
return (
<div>
<h1>Invalid link!</h1>
<Link to="/">Click here to return to the home page</Link>
</div>
);
};
export default ErrorPage;

44
src/components/Header.jsx Normal file
View file

@ -0,0 +1,44 @@
import { NavLink } from "react-router-dom";
import { useMediaQuery } from "react-responsive";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHouse, faShop } from "@fortawesome/free-solid-svg-icons";
import "../styles/Header.scss";
function Header() {
const showText = useMediaQuery({ minWidth: 1000 });
return (
<header className="mx-auto d-flex w-100 px-4">
<div className="mb-3 d-flex flex-grow-1 justify-content-between gap-4">
<NavLink
to="/"
className="d-flex justify-content-center align-items-center logo gap-4"
aria-label="go to home page"
>
<img src="./src/assets/logo.png" alt="dee's fish house logo" />
{showText ? <h1>dee's fish house</h1> : null}
</NavLink>
<nav className="nav nav-masthead justify-content-center float-md-end d-flex align-items-center gap-4">
<NavLink
to="/"
as="button"
type="button"
className="btn btn-outline-light px-4"
>
<FontAwesomeIcon icon={faHouse} /> Home
</NavLink>
<NavLink
to="shop"
as="button"
type="button"
className="btn btn-outline-light px-4"
>
<FontAwesomeIcon icon={faShop} /> Shop
</NavLink>
</nav>
</div>
</header>
);
}
export default Header;

47
src/components/Home.jsx Normal file
View file

@ -0,0 +1,47 @@
import { useMediaQuery } from "react-responsive";
import "../styles/Home.scss";
function Home() {
const Default = () => (
<div className="d-flex flex-column flex-grow-1 justify-content-around px-4">
<div className="d-flex flex-column align-items-center text-center">
<h2 className="fs-1">
Welcome to <span className="fw-bold"> dee's</span>
</h2>
<h3 className="fs-5">Home of the Local Fresh Catch Special</h3>
</div>
<h4 className="fs-6 text-center">
Here at Dee's, we pride ourselves on our freshness. From our daily local
catch to our award-winning nuts, all ingredients are sourced right on
site, and cooked to perfection by our Michelin-certified chefs. This
isn't just any fish restaurant! We strive to provide you a gourmet
experience like no other. Come down to Dee's today to see what all the
hype is about!
</h4>
</div>
);
const isDesktop = useMediaQuery({ minWidth: 1000 });
const Desktop = () => (
<>
<div className="d-flex flex-column justify-content-around gap-4">
<div
className="d-flex flex-grow-1 biglogo"
alt="Dee's Fish House Logo"
/>
<div className="d-flex flex-grow-1 panfrying" />
</div>
<Default />
<div className="d-flex fish"></div>
</>
);
return (
<div className="d-flex flex-row flex-grow-1 justify-content-between home">
{isDesktop ? Desktop() : Default()}
</div>
);
}
export default Home;

37
src/components/Router.jsx Normal file
View file

@ -0,0 +1,37 @@
import { createBrowserRouter, RouterProvider, Outlet } from "react-router-dom";
import ErrorPage from "./ErrorPage.jsx";
import Home from "./Home.jsx";
import Shop from "./Shop.jsx";
import Header from "./Header.jsx";
const Layout = () => (
<div className="d-flex w-100 mx-auto flex-column">
<Header />
<div className="card flex-grow-1 overflow-hidden">
<Outlet />
</div>
</div>
);
const Router = () => {
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{
path: "/",
element: <Home />,
errorElement: <ErrorPage />,
},
{
path: "/shop",
element: <Shop />,
},
],
},
]);
return <RouterProvider router={router} />;
};
export default Router;

355
src/components/Shop.jsx Normal file
View file

@ -0,0 +1,355 @@
import { useState, useEffect, useRef } from "react";
import { useMediaQuery } from "react-responsive";
import { useNavigate } from "react-router-dom";
import { renderToStaticMarkup } from "react-dom/server";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faBagShopping,
faCartShopping,
faPlus,
faMinus,
} from "@fortawesome/free-solid-svg-icons";
import "../styles/Shop.scss";
function Shop() {
const [items, setItems] = useState([]);
const [refresh, setRefresh] = useState(false);
const [scroll, setScroll] = useState(0);
const navigate = useNavigate();
const freshCatchPrice = useRef(
Number.parseFloat((Math.random() * 39.99).toFixed(2))
);
const isDesktop = useMediaQuery({ minWidth: 1000 });
const menu = [
{
name: `Oysters (1 dozen)`,
price: 19.99,
},
{
name: `Beer-battered fish and chips`,
price: 14.99,
},
{
name: `Baja-style fish tacos (3)`,
price: 14.99,
},
{
name: `Local Sandabs with Tartar sauce`,
price: 19.99,
},
{
name: `Seared Ahi Tuna over salad and rice with Sweet Chili sauce`,
price: 24.99,
},
{
name: `Cedar plank smoked Salmon with rice and Terriyaki sauce`,
price: 29.99,
},
{
name: `Halibut with scalloped potatoes and green beans`,
price: 19.99,
},
{
name: `Giant Alaskan Crab meal`,
price: 29.99,
},
{
name: `Chef's Special`,
price: 24.99,
},
{
name: `Catch of the Day`,
price: freshCatchPrice.current,
},
];
const MenuItemContainer = ({ name, price }) => {
function clicked(event) {
// get name of clicked item
const name = event.target.firstChild.textContent;
// check if its already been clicked
let existingItem, index;
for (let i = 0; i < items.length; i++) {
// if yes, copy item
if (items[i].name === name) {
existingItem = {
name: items[i].name,
price: items[i].price,
clicks: items[i].clicks + 1,
};
index = i;
break;
}
}
// then send to new array that is then sent to state
if (existingItem) {
let newArray = items;
newArray[index] = existingItem;
setItems(newArray);
setRefresh(!refresh);
}
// otherwise if the clicked check failed
else {
// find item in menu array
const menuItem = menu.find((item) => item.name === name);
// make new item for cart with clicks set to 0
const newItem = {
name: menuItem.name,
price: menuItem.price,
clicks: 1,
};
// add item to items array
setItems([...items, newItem]);
}
}
return (
<button
type="button"
className="btn border border-1 border-outline border-secondary p-4 d-flex justify-content-between align-items-center w-100 flex-grow-1 gap-5"
onClick={clicked}
>
<div className="fs-6">{name}</div>
<div className="fs-6">{`$${price}`}</div>
</button>
);
};
// if mobile, menu is displayed in one div otherwise in two
const Mobile = () => {
const output = menu.map((menuItem, index) => (
<MenuItemContainer
name={menuItem.name}
price={menuItem.price}
key={index}
/>
));
return (
<>
<div className="d-flex flex-column p-4 border border-secondary gap-4 overflow-scroll border-end-0">
{output}
</div>
</>
);
};
const Desktop = () => {
let left, right;
const middle = Math.floor(menu.length / 2);
[left, right] = [menu.slice(0, middle), menu.slice(middle, menu.length)];
left = left.map((menuItem, index) => (
<MenuItemContainer
name={menuItem.name}
price={menuItem.price}
key={index}
/>
));
right = right.map((menuItem, index) => (
<MenuItemContainer
name={menuItem.name}
price={menuItem.price}
key={index + (left.length - 1)}
/>
));
return (
<>
<div className="d-inline-flex p-4 border border-secondary gap-4 overflow-scrolls border-end-0 justify-content-evenly">
<div className="d-inline-flex flex-column gap-4">{left}</div>
<div className="d-inline-flex flex-column gap-4">{right}</div>
</div>
</>
);
};
const CartItemContainer = ({ name, price, clicks }) => {
function handleChange(event) {
// get name of associated item in cart
const itemName =
event.currentTarget.parentNode.parentNode.parentNode.firstChild
.textContent;
// find associated item
let item, index, delbool;
for (let i = 0; i < items.length; i++) {
// if found
if (items[i].name === itemName) {
item = items[i];
index = i;
break;
}
}
let newArray = items;
function setStates() {
setItems(newArray);
setRefresh(!refresh);
if (scroll != document.querySelector(".cart").scrollTop)
setScroll(document.querySelector(".cart").scrollTop);
return;
}
if (event.currentTarget.id === "quantity") {
function finish() {
if (event.currentTarget.value != item.value) {
if (event.currentTarget.value < 0) delbool = true;
if (delbool) {
newArray.splice(index, 1);
} else {
item.clicks = event.currentTarget.value;
newArray[index] = item;
}
setStates();
}
}
if (event.key === "Enter" || event.key === "Tab") {
finish();
}
if (event.key === "Backspace") {
event.currentTarget.value = event.currentTarget.value.slice(0, -1);
return;
}
}
if (
event.currentTarget.id === "plus" ||
event.currentTarget.id === "minus"
) {
// handle changes - plus
if (event.currentTarget.id === "plus") {
item.clicks += 1;
}
// handle changes - minus
if (event.currentTarget.id === "minus") {
item.clicks -= 1;
if (item.clicks < 1) {
delbool = true;
}
}
// assign to new array, then to state
let newArray = items;
if (delbool) {
newArray.splice(index, 1);
} else {
newArray[index] = item;
}
setStates();
}
}
return (
<div className="border border-secondary rounded p-4 d-flex justify-content-between align-items-center gap-4">
<div className="fs-6">{name}</div>
<div className="d-flex gap-4 align-items-center justify-content-end">
<div className="fs-6">{`$${price}`}</div>
<div className="d-flex gap-2 align-items-equal align-items-center">
<button
type="button"
className="btn p-2"
id="plus"
onClick={handleChange}
>
<FontAwesomeIcon icon={faPlus} />
</button>
<input
type="text"
className="form-control fs-6 p-0"
aria-label="item quantity"
id="quantity"
onKeyDown={handleChange}
size="3"
placeholder={clicks}
/>
<button
type="button"
className="btn p-2"
id="minus"
onClick={handleChange}
>
<FontAwesomeIcon icon={faMinus} />
</button>
</div>
</div>
</div>
);
};
const Cart = () => {
const output = items.map((item, index) => (
<CartItemContainer
name={item.name}
price={item.price}
clicks={item.clicks}
key={index}
/>
));
return (
<div className="d-flex flex-column p-4 border border-secondary gap-2 overflow-scroll cart">
{output}
</div>
);
};
// runs on first load
useEffect(() => {
// sets up additional cart output div on navbar
const nav = document.querySelector("nav");
const cart = document.createElement("div");
cart.className =
"border border-1 border-color-white rounded-2 text-white px-4 cart-icon";
cart.innerHTML = renderToStaticMarkup(
<>
<FontAwesomeIcon icon={faCartShopping} /> Cart ({items.length})
</>
);
nav.appendChild(cart);
// sets up dummy checkout button on navbar
const checkout = document.createElement("button");
checkout.className = "btn btn-success px-4 checkout";
checkout.onclick = () => {
alert(`Thank you for shopping at Dee's Fish House! Please come again!`);
navigate("/");
};
checkout.innerHTML = renderToStaticMarkup(
<>
<FontAwesomeIcon icon={faBagShopping} /> Check Out
</>
);
nav.appendChild(checkout);
return () => {
nav.removeChild(cart);
nav.removeChild(checkout);
};
}, []);
// updates items in cart when items updates
useEffect(() => {
let finalCount = 0;
items.forEach((item) => {
item.clicks > 0
? (finalCount = finalCount * 1 + item.clicks * 1)
: finalCount++;
});
document.querySelector(".cart-icon").innerHTML = renderToStaticMarkup(
<>
<FontAwesomeIcon icon={faCartShopping} /> Cart ({finalCount})
</>
);
document.querySelector(".cart").scrollTop = scroll;
}, [items, refresh, scroll]);
return (
<div className="d-flex flex-row justify-content-between p-4 shop flex-grow-1 overflow-hidden">
{isDesktop ? Desktop() : Mobile()}
<Cart />
</div>
);
}
export default Shop;

10
src/components/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import Router from "./Router.jsx";
import "../styles/main.scss";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<Router />
</React.StrictMode>
);

27
src/styles/Header.scss Normal file
View file

@ -0,0 +1,27 @@
a > img {
height: 2.75rem;
}
.nav-masthead.focus {
background-color: rgba(255, 255, 255, 0.75);
}
.nav-masthead .active {
color: black !important;
}
.btn.active {
background-color: rgba(255, 255, 255, 0.75) !important;
}
.logo,
.fs-1 > span {
color: red;
text-decoration: none;
font-family: "Cedarville Cursive";
text-shadow: 1px 1px 0.5px black;
}
.logo > h1 {
font-size: 1.9rem;
}

32
src/styles/Home.scss Normal file
View file

@ -0,0 +1,32 @@
.fish {
background-image: url("../assets/fishies.jpg");
background-size: cover;
background-position: center;
border-radius: 1rem !important;
}
.biglogo {
background-image: url("../assets/logo.png");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
width: 100%;
border-radius: 1rem !important;
}
.panfrying {
background-image: url("../assets/panfrying.jpg");
background-size: cover;
background-repeat: no-repeat;
background-position: center;
width: 100%;
border-radius: 1rem !important;
}
@media screen and (min-width: 1000px) {
.home > .d-flex {
width: 31%;
padding-left: 0rem;
padding-right: 0rem;
}
}

90
src/styles/Shop.scss Normal file
View file

@ -0,0 +1,90 @@
.cart-icon {
padding-top: 0.375rem;
padding-bottom: 0.375rem;
}
.shop {
max-height: 100%;
}
.shop > div {
box-shadow: inset 0 0 1rem rgba(#6c757d, 0.75);
}
@media screen and (max-width: 500px) {
.shop > div > button {
flex-direction: column;
row-gap: 1rem !important;
}
.shop > div {
row-gap: 1rem !important;
}
.cart > .d-flex > .d-flex > .d-flex {
flex-direction: column;
}
}
@media screen and (max-width: 999px) {
.shop > div > button {
backdrop-filter: blur(4px);
}
.shop > div > button:hover {
background-color: #fff;
box-shadow: 0 0 1rem 0.4rem rgba(#0d6efd, 0.75);
}
.shop > div:first-child {
width: 60%;
}
.cart {
width: 40%;
}
}
@media screen and (min-width: 1000px) {
.shop > div > div > button {
backdrop-filter: blur(4px);
}
.shop > div > div > button:hover {
background-color: #fff;
box-shadow: 0 0 1rem 0.4rem rgba(#0d6efd, 0.75);
}
.shop > div:first-child {
width: 75%;
}
.cart {
width: 25%;
}
}
@media screen and (max-width: 1000px) {
.cart > .d-flex > .d-flex {
flex-direction: column;
}
}
@media screen and (max-width: 1500px) {
.cart > .d-flex {
flex-direction: column;
}
}
input {
min-width: 3ch;
max-width: 3ch;
}
.cart > .d-flex {
backdrop-filter: blur(4px);
text-shadow: none;
}
.cart > .d-flex > .d-flex > .d-flex > button:hover {
background-color: #fff;
box-shadow: 0 0 0.2rem 0.2rem rgba(#0d6efd, 0.75);
}

60
src/styles/main.scss Normal file
View file

@ -0,0 +1,60 @@
$input-focus-border-color: white;
$input-btn-focus-box-shadow: 0 0 0 0.25rem rgba(255, 255, 255, 0.25);
$form-select-indicator-color: $input-focus-border-color;
$form-select-focus-box-shadow: $input-btn-focus-box-shadow;
$enable-grid-classes: false;
$enable-cssgrid: true;
@import "../node_modules/bootstrap/scss/bootstrap";
#root {
margin-left: auto;
margin-right: auto;
padding: 1rem;
display: flex;
flex-direction: column;
flex: 0 0 100%;
width: 100%;
}
body {
margin: 0;
height: 100vh;
max-height: 100vh;
text-shadow: 0 0.05rem 0.1rem rgba(0, 0, 0, 0.25);
background-image: url("../assets/waves.jpg");
background-size: 100% 100%;
display: flex;
overflow: hidden;
}
#root > .d-flex {
flex: 0 0 100%;
max-height: 100%;
}
.card {
color: black;
background-color: rgba(255, 255, 255, 0.75);
border-radius: 1rem !important;
border-width: 0.125rem;
}
.nav-masthead .nav-link {
color: rgba(255, 255, 255, 0.5);
border-bottom: 0.25rem solid transparent;
}
.nav-masthead .nav-link:hover,
.nav-masthead .nav-link:focus {
border-bottom-color: rgba(255, 255, 255, 0.25);
}
.nav-masthead .nav-link + .nav-link {
margin-left: 1rem;
}
.nav-masthead .active {
color: #fff;
border-bottom-color: #fff;
}

11
tests/setup.js Normal file
View file

@ -0,0 +1,11 @@
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import matchers from "@testing-library/jest-dom/matchers";
// extends Vitest's expect method with methods from react-testing-library
expect.extend(matchers);
// runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
});

12
vite.config.js Normal file
View file

@ -0,0 +1,12 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./tests/setup.js",
},
});