Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
7b112ed
convert prismaClient.js to TypeScript
nihodi Oct 2, 2025
6dd323c
WIP menu
nihodi Oct 22, 2025
3c92793
require being logged in to access /volunteering/menu
nihodi Oct 22, 2025
05492ba
move GET /menu/products endpoint to /menu (because it makes more sense)
nihodi Oct 22, 2025
4b1520f
use prisma's autogenerated type definition for MenuProduct instead of…
nihodi Oct 22, 2025
a50dbd5
add auth checks to menu API
nihodi Oct 22, 2025
6e6f8bb
separate product and category related functions into their own files
nihodi Oct 22, 2025
86e7f9c
remove support for uncategorized items
nihodi Oct 22, 2025
7b64128
switch to a much cleaner Grid based layout
nihodi Oct 22, 2025
5d01d78
move `NewProduct` and `createProduct` to product.tsx
nihodi Oct 22, 2025
6d21137
add validation to category name inputs
nihodi Oct 22, 2025
2858d09
add validation to the rest of the inputs
nihodi Oct 22, 2025
2db8590
change active and glutenfree to be booleans in the database schema
nihodi Oct 23, 2025
65658f1
add input for gluten free
nihodi Oct 23, 2025
39e25ee
fix product validation
nihodi Oct 23, 2025
d302e80
add spinner when updating/creating new product
nihodi Oct 23, 2025
ac76e26
disable "Create" button when input is invalid
nihodi Oct 23, 2025
d50e8a1
add spinner when creating/updating category
nihodi Oct 23, 2025
f8a137f
add API endpoint for deleting products
nihodi Oct 23, 2025
666b1be
add button for deleting products
nihodi Oct 23, 2025
d67cefa
redesign customer-facing menu (`/escape/menu` page)
nihodi Oct 29, 2025
c572128
add display for gluten-free products
nihodi Oct 29, 2025
9171dd8
add responsivity to bar menu
nihodi Oct 29, 2025
b6e1866
convert escape menu to be server-side
nihodi Oct 30, 2025
c685e7b
redo menu grid column sizes
nihodi Oct 30, 2025
7fbf534
rename `MenuProduct.active` column to `hidden`
nihodi Oct 30, 2025
6539ee0
add checkbox input to hide/un-hide products
nihodi Oct 30, 2025
7690f19
only display non-hidden items on the menu
nihodi Oct 30, 2025
4b029d1
add some comments
nihodi Oct 30, 2025
46e9d78
add basic API validation
nihodi Oct 31, 2025
93ab40f
default setting `priceVolunteer` to `price` of the product due to uni…
nihodi Oct 31, 2025
5b801d3
fix API validation breaking POST /escape/products endpoint
nihodi Oct 31, 2025
8b9b8b3
align buttons better
nihodi Oct 31, 2025
1be22db
extract DeletionConfirmationDialog into a generic component
nihodi Oct 31, 2025
e6dad7b
add ability to delete categories
nihodi Oct 31, 2025
5544bf8
wrap all relevant API returns with `auth.verify()`
nihodi Nov 6, 2025
c71adee
rename all `authCheck`s to `auth` for consistency
nihodi Nov 6, 2025
0887c8e
return HTTP `201 Created` (instead of just `200 Ok`) for relevant API…
nihodi Nov 6, 2025
ec7d5a1
wrap missing API return with `auth.verify()`
nihodi Nov 6, 2025
5586298
add server-side check to only permit users with the `admin` role to c…
nihodi Nov 6, 2025
58c6253
extract API utility types into their own files/directory
nihodi Nov 6, 2025
ab94b59
even more `auth.verify()`
nihodi Nov 6, 2025
1741305
remove `console.log` in `DeletionConfirmationDialog.tsx`
nihodi Nov 6, 2025
ca0fe7f
only show validation error when creating a new menu category after us…
nihodi Nov 6, 2025
c90b8c9
extract large onClick method into its own function
nihodi Nov 6, 2025
d0312d9
add database error checking for menu API endpoints
nihodi Nov 6, 2025
942195b
actually use transaction
nihodi Nov 6, 2025
fdda652
fix validation immediately validating `Category name` field after cre…
nihodi Nov 7, 2025
0587b96
add link to escape menu to the `AppBar`
nihodi Nov 7, 2025
e8fd7bf
require `admin` role to load `MenuEditPage`
nihodi Nov 12, 2025
b1211c6
move link to menu editor from the /volunteering page to /board
nihodi Nov 12, 2025
ed00d43
improve grid column spacing and text wrapping in the menu
nihodi Nov 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
245 changes: 245 additions & 0 deletions app/(pages)/(main)/board/menu/category.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// This file contains react components for individual categories, and relevant utility functions.

import { useEffect, useState } from "react";
import { Button, Grid, Input, Stack, TextField, Typography } from "@mui/material";
import { MenuCategory } from "@prisma/client";

import { NewProduct, Product } from "@/app/(pages)/(main)/board/menu/product";
import { styled } from "@mui/system";
import CircularProgress from "@mui/material/CircularProgress";
import { DeletionConfirmationDialog } from "@/app/components/input/DeletionConfirmationDialog";
import { MenuCategoryCreate, MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes";


// Updates a given category.
function updateCategory(category: MenuCategoryWithProducts, newAttributes: Partial<MenuCategory>): Promise<Response> {

return fetch("/api/v2/escape/menu/categories", {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({...category, ...newAttributes, ...{menu_products: undefined}}) // set menu_products to undefined, because trying to PATCH the category with products makes Prisma angry
});
}

// Creates a given category.
function createCategory(category: MenuCategoryCreate): Promise<Response> {
return fetch("/api/v2/escape/menu/categories", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(category)
});
}

function deleteCategory(categoryId: number): Promise<Response> {
return fetch(`/api/v2/escape/menu/categories/${ categoryId }`, {
method: "DELETE",
});
}

// a <Textfield> with larger text. Equivalent font-size to <h2>
const LargeTextField = styled(TextField)({
"& .MuiOutlinedInput-input": {
fontSize: "3.75rem",
}
})

export function Category(
props: {
category: MenuCategoryWithProducts,
onUpdate: () => void // called when the category has been updated in the database.
}
) {

let [categoryName, setCategoryName] = useState<string>(props.category.name);

// validate category name. Category name cannot be empty.
const categoryNameInvalid: boolean = categoryName.trim() === "";

// these are used to disable the UPDATE button when user has not inputted anything yet.
let [hasBeenUpdated, setHasBeenUpdated] = useState<boolean>(false);
let [isFirst, setIsFirst] = useState<boolean>(true);

// is used for the spinner inside the UPDATE button
let [isUpdating, setIsUpdating] = useState<boolean>(false);


let [deleteDialogOpen, setDeleteDialogOpen] = useState<boolean>(false);
let [isDeleting, setIsDeleting] = useState<boolean>(false);


useEffect(() => {
// useEffect is always run once at the start.
if (isFirst) {
setIsFirst(false);
return;
}

// this happens when categoryName has been updated for the first time.
setHasBeenUpdated(true);
}, [categoryName]);

return (
<Stack>
<Grid container spacing={ 2 } columns={ 10 }>

<Grid item xs={ 8 }>
<LargeTextField
type="text"
value={ categoryName }
onChange={ (e) => {
setCategoryName(e.target.value)
} }

required
label="Category Name"
placeholder="Category Name"
error={ categoryNameInvalid }
helperText={ categoryNameInvalid ? "Name must not be empty" : "" }

></LargeTextField>
</Grid>

<Grid item xs={ 1 } display="flex" alignItems="center">
<Button // UPDATE button
disabled={ !hasBeenUpdated || categoryNameInvalid || isUpdating }
onClick={ () => {
setIsUpdating(true);
updateCategory(
props.category,
{name: categoryName}
).then(() => {
setHasBeenUpdated(false);
setIsFirst(true);
setIsUpdating(false);
props.onUpdate();
});
}
}
>
{
isUpdating ? <CircularProgress/> : <>Update</> // show spinner when updating is in progress
}
</Button>
</Grid>

<Grid item xs={ 1 } display="flex" alignItems="center">
<Button
color="error"
onClick={ () => {
setDeleteDialogOpen(true);
} }
>
Delete
</Button>
</Grid>
</Grid>


<Grid container spacing={ 2 } columns={ 10 }>

<Grid item xs={ 2 }>
<Typography variant="h5">Name</Typography>
</Grid>
<Grid item xs={ 2 }>
<Typography variant="h5">Price</Typography>
</Grid>
<Grid item xs={ 2 }>
<Typography variant="h5">Volume (cL)</Typography>
</Grid>
<Grid item xs={ 3 }>
<div></div>
</Grid>

{
props.category.menu_products.map((item) => (
<Product product={ item } key={ item.id } onUpdate={ props.onUpdate }></Product>
)
)
}

<NewProduct onUpdate={ props.onUpdate } categoryId={ props.category.id }></NewProduct>
</Grid>

<DeletionConfirmationDialog
open={ deleteDialogOpen }
onClose={ () => setDeleteDialogOpen(false) }
onDelete={ () => {
setIsDeleting(true);
deleteCategory(props.category.id).then(() => {
props.onUpdate();
});
} }
showSpinner={ isDeleting }
title="Delete category?"
>
Are you sure you want to delete this category?
</DeletionConfirmationDialog>
</Stack>
)
}

export function NewCategory(props: { onUpdate: () => void }) {
let [categoryName, setCategoryName] = useState<string>("");
let [isCreating, setIsCreating] = useState<boolean>(false);

let [hasBeenUpdated, setHasBeenUpdated] = useState<boolean>(false);
let [isFirst, setIsFirst] = useState<boolean>(true);

useEffect(() => {
if (isFirst) {
setIsFirst(false);
return;
}

setHasBeenUpdated(true);
}, [categoryName]);


const invalid = categoryName.trim() === "";

// called when user clicks the CREATE button
const createNewCategory = () => {
setIsCreating(true);
createCategory(
{name: categoryName}
).then(() => {
setCategoryName("");
setIsCreating(false);
setIsFirst(true);
setHasBeenUpdated(false);
props.onUpdate();
});
};

return (
<Stack justifyContent="space-between" spacing={ 2 }>
<Typography variant="h3">New Category</Typography>

<TextField
type="text"
value={ categoryName }
placeholder="Category Name"
label="Category Name"
onChange={ (e) => setCategoryName(e.target.value) }
style={ {fontSize: "3.75rem"} }

required
error={ invalid && hasBeenUpdated}
helperText={ invalid && hasBeenUpdated ? "Name must not be empty" : "" }
></TextField>

<Button

disabled={ invalid || !hasBeenUpdated }

onClick={ createNewCategory }
>{ isCreating ? <CircularProgress/> : <>Create</> }</Button>


</Stack>
)
}
46 changes: 46 additions & 0 deletions app/(pages)/(main)/board/menu/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"use client";

import { Stack } from "@mui/material";
import { useEffect, useState } from "react";
import authWrapper from "@/app/middleware/authWrapper";
import { Category, NewCategory } from "@/app/(pages)/(main)/board/menu/category";
import { MenuCategoryWithProducts } from "@/app/api/utils/types/MenuCategoryTypes";

// require login
export default authWrapper(MenuEditPage, "admin");

function MenuEditPage() {
const [menuCategories, setMenuCategories] = useState<MenuCategoryWithProducts[]>([]);

useEffect(() => {
refetchMenu();
}, []);

const refetchMenu = () => fetchMenu().then(menu => setMenuCategories(menu));

return (
<Stack spacing={ 10 }>
{
menuCategories.map((item) => {
return (
<Category
category={ item }
key={ item.id }
onUpdate={
refetchMenu // refetch the menu when somthing has been updated
}
></Category>
);
})
}

<NewCategory onUpdate={ refetchMenu }></NewCategory>
</Stack>
)
}

async function fetchMenu(): Promise<MenuCategoryWithProducts[]> {
const menu = await fetch("/api/v2/escape/menu");
return await menu.json();
}

Loading