Categories
Dishes
No dishes yet. They arrive on Day 4.
New class, already started on sign-up and login with bcrypt. Deadline Sunday 4 October. Design does not matter, working features do. Every day ships one feature end to end: server first, tested in Postman, then the page that uses it.
7 class days (Thu 24 Sep – Fri 2 Oct) · 2 buffer days · 4 models · 15 endpoints
Login and sign-up → categories → foods (the first reference between two tables) → Cloudinary → customer side and cart → orders (two more references). Each step needs the one before it.
No design work. Use shadcn Input, Label, Button, Dialog, Select as they come. Each day below shows what its screen should look like and what to validate.
Already started: sign-up and login with bcrypt. Today closes it out.
user-schema.js: email (unique), password, role (USER / ADMIN, default USER), address.userId and role.require-token.js and require-admin.js middleware.POST /auth/sign-up public
POST /auth/login public
Done when: wrong password is rejected, no response contains a password, login returns a token.
_api/api.js: one axios instance with the base URL. It reads the token from localStorage and adds it to every request.token and user straight to localStorage. No provider yet./admin/dishes, USER goes to /.On a real screen the form sits in the middle, both ways, about 380px wide. Everything around it is empty page. In Tailwind: min-h-screen flex items-center justify-center on the page, w-full max-w-sm on the form.
emailrequired, has @ and a dotEnter a valid emailpasswordsign-up: 8+ characters · login: requiredAt least 8 charactersconfirmsame as passwordPasswords don't matchserver 409email already usedThis email already has an accountserver 401one message for wrong email or passwordEmail or password is wrongDone when: a new account can sign up and log in, and the token is in DevTools → Application → Local Storage. The admin and the user land on different pages.
food-category.js schema, 4 routes. Create, update and delete sit behind requireToken + requireAdmin.GET /food-category public
POST /food-category token + admin
PUT /food-category token + admin
DELETE /food-category token + admin
admin/dishes → category-sidebar.js: list, add, rename, delete.No dishes yet. They arrive on Day 4.
Left menu comes from admin/layout.js. The page is two columns: categories, then dishes. On a phone they stack.
namerequired after trim, 2–30 charactersName is requiredserver 409name already existsThis category already existsdeleteask first, Cancel changes nothingDelete "Pizza"?Done when: a category added in the browser is in Atlas and survives a refresh. The same call from Postman without a token gets 401.
food.js schema: name, price, image, ingredients, category as ObjectId with ref: "FoodCategory"..populate("category") and takes ?categoryId=.GET /food public
GET /food/:id public
POST /food token + admin
PUT /food/:id token + admin
DELETE /food/:id token + admin
dish-grid.js and dish-form-dialog.js: list and create. Image is a pasted URL for today.Clicking a category in the left list filters the grid. The rename and delete buttons from Day 3 are still there, hidden here to keep it short.
namerequired after trimFood name is requiredpricea number above 0Price must be a number above 0categoryone is chosenChoose a categoryingredientsoptional—Done when: a dish comes back with its category name, not a bare id. Clicking a category shows only its dishes.
No new screens. Categories and dishes already work with local state from Days 3–4. Today that state moves into providers, so the admin page and the Day 6 home page read the same data.
auth-provider.js: user + token, reads localStorage once, gives login / logout. admin/layout.js uses it to send non-admins back to login.category-provider.js: move the useState + fetch out of category-sidebar.js. Gives categories, addCategory, renameCategory, deleteCategory.dish-provider.js: move the dish list + fetch out of dish-grid.js. Gives dishes, addDish. Adding a dish also refreshes category counts.app/layout.js wraps all three.// Day 3: category-sidebar.js owns the data
const [categories, setCategories] = useState([]);
useEffect(() => {
api.get("/food-category")
.then((res) => setCategories(res.data));
}, []);// Day 5: the provider owns it, the feature asks const { categories, addCategory } = useCategories(); // same JSX as before, nothing else changes
image-upload.js in the Add dish dialog. The browser uploads straight to Cloudinary (unsigned preset) and puts the URL into the form.imagea file is picked and the upload finishedWait for the image to uploadimage typestarts with image/Choose an image fileDone when: the admin page works exactly like yesterday, but no feature file calls api any more. A USER typing /admin gets bounced. A new dish saves a short https://res.cloudinary.com/... URL, never base64.
dish-provider.js: updateDish, deleteDish. The pencil opens dish-form-dialog.js pre-filled. Delete uses confirm-dialog.js.useCategories() and useDishes(). No new fetch code for it.order.js schema: user ref User, items[] each with food ref Food + quantity + price copied at order time, total, status, address.POST /order ignores any price the client sends and looks it up. GET /order/me.POST /order token
GET /order/me token
(main)/page.js: category tabs + food grid. Click a food to add it to the cart.providers/cart-provider.js: items, add, remove, quantity, total. Saved in localStorage.editsame rules as Add dish, fields start pre-filled—The header lives in (main)/layout.js, so it stays on every customer page. The chips filter the grid.
add to cartsame food twice = quantity 2, not two lines—not logged insend to login before addingLog in to orderDone when: edit and delete update the grid with no refresh. an order sent from Postman with price 1 is saved with the real price. The cart survives a refresh.
Build orders with local state inside the features first, like categories on Day 3. They move into order-provider.js on Saturday.
GET /order (all orders, admin) with user and food populated. PATCH /order/:id changes status.GET /order token + admin
PATCH /order/:id token + admin
admin/orders: table with customer email, items, total, status select.(main)/orders/page.js: my orders. First to cut if late.| # | Customer | Items | Total | Address | Status |
|---|---|---|---|---|---|
| 1 | bold@gmail.com | 3 | ₮48,000 | Khan-Uul, 15th khoroo | |
| 2 | saraa@gmail.com | 1 | ₮15,000 | Sukhbaatar, 1st khoroo |
cartat least 1 itemPlace order disabledquantitynever below 1 (− is disabled at 1)—addressrequired, 5+ charactersEnter a delivery addressstatusonly PENDING, DELIVERED, CANCELED (enum on the server)—Done when: a customer places an order and the admin, in another browser, changes it to Delivered.
order-provider.js: myOrders, allOrders, placeOrder, updateStatus. Same rule as Day 5: the screens must work exactly as before.The finished layout on Day 7. Green = created after auth is done. Grey notes say what the file holds and which day writes it.
A piece of the page that owns data: it fetches, holds state, or submits. Example: category-sidebar.js loads the list and calls the API.
A small piece that only shows what it is given through props. No fetch, no API call. Example: dish-card.js gets one dish and draws it.
Puts features side by side. Keep it short.
server/ ├── index.js mounts every router ├── connectDB.js ├── .env MONGO_URI, JWT_SECRET ├── schemas/ │ ├── user-schema.js D1 │ ├── food-category.js D3 │ ├── food.js D4 │ └── order.js D6 ├── middleware/ │ ├── require-token.js D1 │ └── require-admin.js D1 ├── controllers/ │ ├── auth/auth.js │ ├── food-category/ 4 files │ ├── food/ 5 files │ └── order/ 4 files └── router/ ├── auth/auth.js ├── food-category/food-category-router.js ├── food/food-router.js └── order/order-router.js
src/ ├── app/ │ ├── layout.js wraps Auth, Category, Dish, Order providers │ ├── _api/api.js axios + token · D2 │ ├── (auth)/ │ │ ├── layout.js centers the form · D2 │ │ ├── _components/field-error.js red text under an input · D2 │ │ ├── login/ │ │ │ ├── page.js │ │ │ └── _features/login-form.js D2 │ │ └── signup/ │ │ ├── page.js │ │ └── _features/signup-form.js D2 │ ├── (main)/ │ │ ├── layout.js Header + CartProvider · D6 │ │ ├── page.js home · D6 │ │ ├── _components/ │ │ │ ├── header.js logo, Cart (n), Log out · D6 │ │ │ ├── food-card.js image, name, price, Add · D6 │ │ │ └── cart-item.js name, − qty + · D7 │ │ ├── _features/ │ │ │ ├── category-chips.js D6 │ │ │ ├── food-grid.js D6 │ │ │ └── cart-sheet.js items, total, address · D7 │ │ └── orders/ │ │ ├── page.js my orders · D7 │ │ └── _components/order-row.js D7 │ └── admin/ │ ├── layout.js sidebar + guard · D3, guard D5 │ ├── _components/ │ │ ├── sidebar.js Dishes, Orders · D3 │ │ └── confirm-dialog.js "Delete X?" · D3 │ ├── dishes/ │ │ ├── page.js two columns · D3 │ │ ├── _components/dish-card.js D4 │ │ └── _features/ │ │ ├── category-sidebar.js list, add, rename, delete · D3 │ │ ├── dish-grid.js D4 │ │ ├── dish-form-dialog.js add D4, edit D5 │ │ └── image-upload.js Cloudinary · D5 │ └── orders/ │ ├── page.js D7 │ ├── _components/status-select.js D7 │ └── _features/orders-table.js D7 ├── providers/ │ ├── auth-provider.js user, token, login, logout · D5 │ ├── category-provider.js migrated from Day 3 · D5 │ ├── dish-provider.js migrated from Day 4, edit/delete D6 │ ├── cart-provider.js D6 │ └── order-provider.js migrated from Day 7 · Sat ├── components/ui/ shadcn └── lib/utils.js
app/page.js on Day 6. It and (main)/page.js both mean /, and Next.js refuses to build with two.| Model | Fields | Points to |
|---|---|---|
| User | email, password (hashed), role, address | nothing |
| FoodCategory | categoryName | nothing |
| Food | name, price, image, ingredients, category | category → FoodCategory |
| Order | user, items[ food, quantity, price ], total, status, address | user → User, items.food → Food |
A reference stores the other document's id. populate() swaps the id for the real document when reading. Order items also copy the price, so changing a food's price later does not change old orders.
| Day | Route | Access | Middleware on the route |
|---|---|---|---|
| 1 | POST /auth/sign-up | public | none |
| 1 | POST /auth/login | public | none |
| 3 | GET /food-category | public | none |
| 3 | POST /food-category | admin | requireToken, requireAdmin |
| 3 | PUT /food-category | admin | requireToken, requireAdmin |
| 3 | DELETE /food-category | admin | requireToken, requireAdmin |
| 4 | GET /food | public | none |
| 4 | GET /food/:id | public | none |
| 4 | POST /food | admin | requireToken, requireAdmin |
| 4 | PUT /food/:id | admin | requireToken, requireAdmin |
| 4 | DELETE /food/:id | admin | requireToken, requireAdmin |
| 6 | POST /order | token | requireToken |
| 6 | GET /order/me | token | requireToken |
| 7 | GET /order | admin | requireToken, requireAdmin |
| 7 | PATCH /order/:id | admin | requireToken, requireAdmin |
requireToken always runs before requireAdmin. requireToken reads the JWT and puts the user on req.user. requireAdmin only checks req.user.role === "ADMIN", so without the token step it has nothing to check.
router.post("/", requireToken, requireAdmin, createFood)Public = anyone, no login. Token = any logged-in user, answers 401 without a valid token. Admin = logged in with role ADMIN, answers 401 with no token and 403 for a USER. POST /order and GET /order/me take the user id from the token, never from the request body.
Signed Cloudinary uploads (unsigned is fine for class) · password reset · search · settings page · automated tests (Postman is the test) · visual design polish.