From af7ee5e9c134245766c5125defdac45ed9de9835 Mon Sep 17 00:00:00 2001 From: feyishola Date: Sun, 8 Mar 2026 09:30:30 +0100 Subject: [PATCH 1/4] input validation middleware feature implemented --- web/.eslintrc.cjs | 10 ++ web/README.md | 24 +++ web/index.html | 12 ++ web/package.json | 28 ++++ web/src/App.tsx | 360 ++++++++++++++++++++++++++++++++++++++++++ web/src/api.ts | 126 +++++++++++++++ web/src/main.tsx | 10 ++ web/src/styles.css | 351 ++++++++++++++++++++++++++++++++++++++++ web/src/theme.ts | 19 +++ web/src/types.ts | 15 ++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 16 ++ web/vite.config.ts | 6 + 13 files changed, 978 insertions(+) create mode 100644 web/.eslintrc.cjs create mode 100644 web/README.md create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.tsx create mode 100644 web/src/api.ts create mode 100644 web/src/main.tsx create mode 100644 web/src/styles.css create mode 100644 web/src/theme.ts create mode 100644 web/src/types.ts create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/web/.eslintrc.cjs b/web/.eslintrc.cjs new file mode 100644 index 0000000..54d1a92 --- /dev/null +++ b/web/.eslintrc.cjs @@ -0,0 +1,10 @@ +module.exports = { + root: true, + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + }, + plugins: ["@typescript-eslint"], + extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..411b527 --- /dev/null +++ b/web/README.md @@ -0,0 +1,24 @@ +# Discoverly Restaurant Dashboard + +Lightweight React/Vite dashboard for restaurant menu management. + +## Run + +```bash +npm install -w web +npm run dev -w web +``` + +## Environment + +Optional: + +- `VITE_API_BASE_URL` (default: `http://localhost:4000`) + +The dashboard expects: + +- `POST /api/upload` +- `GET /api/restaurant/foods` +- `POST /api/restaurant/foods` +- `PUT /api/restaurant/foods/:id` +- `DELETE /api/restaurant/foods/:id` diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..7e342b1 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ + + + + + + Discoverly Restaurant Dashboard + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c62e378 --- /dev/null +++ b/web/package.json @@ -0,0 +1,28 @@ +{ + "name": "@discoverly/web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint \"src/**/*.{ts,tsx}\"", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@types/react": "^18.3.5", + "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "^7.18.0", + "@typescript-eslint/parser": "^7.18.0", + "@vitejs/plugin-react": "^4.3.1", + "eslint": "^8.57.1", + "eslint-config-prettier": "^9.1.0", + "typescript": "^5.6.2", + "vite": "^5.4.10" + } +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000..4fe9724 --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,360 @@ +import { FormEvent, useEffect, useMemo, useState } from "react" +import { createFood, deleteFood, listRestaurantFoods, updateFood, uploadFoodImage } from "./api" +import { tokens } from "./theme" +import type { FoodItem, FoodPayload } from "./types" + +type FormState = { + name: string + description: string + price: string + image_url: string +} + +const EMPTY_FORM: FormState = { + name: "", + description: "", + price: "", + image_url: "", +} + +function mapFoodToForm(item: FoodItem): FormState { + return { + name: item.name, + description: item.description, + price: String(item.price), + image_url: item.image_url, + } +} + +function toPayload(form: FormState): FoodPayload { + return { + name: form.name.trim(), + description: form.description.trim(), + price: Number(form.price), + image_url: form.image_url.trim(), + } +} + +export function App() { + const [token, setToken] = useState(() => localStorage.getItem("discoverly.restaurant.token") ?? "") + const [foods, setFoods] = useState([]) + const [loadingFoods, setLoadingFoods] = useState(false) + const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [editingId, setEditingId] = useState(null) + const [previewUrl, setPreviewUrl] = useState("") + + const isEditing = editingId !== null + const canSubmit = useMemo( + () => Boolean(form.name.trim() && form.description.trim() && form.image_url.trim() && Number(form.price) > 0), + [form.description, form.image_url, form.name, form.price], + ) + + useEffect(() => { + localStorage.setItem("discoverly.restaurant.token", token) + }, [token]) + + const loadFoods = async () => { + if (!token.trim()) { + setFoods([]) + return + } + + setLoadingFoods(true) + setError(null) + try { + const data = await listRestaurantFoods(token.trim()) + setFoods(data.filter((item) => item.is_active !== false)) + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to load foods" + setError(message) + } finally { + setLoadingFoods(false) + } + } + + useEffect(() => { + void loadFoods() + }, [token]) + + const onSubmit = async (event: FormEvent) => { + event.preventDefault() + setError(null) + setSuccess(null) + + if (!token.trim()) { + setError("Add restaurant auth token before saving.") + return + } + + if (!canSubmit) { + setError("Please fill name, description, valid price, and image URL.") + return + } + + setSaving(true) + try { + const payload = toPayload(form) + if (editingId) { + await updateFood(token.trim(), editingId, payload) + setSuccess("Food item updated.") + } else { + await createFood(token.trim(), payload) + setSuccess("Food item created.") + } + + setForm(EMPTY_FORM) + setEditingId(null) + setPreviewUrl("") + await loadFoods() + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to save item" + setError(message) + } finally { + setSaving(false) + } + } + + const onUpload = async (file: File | null) => { + if (!file) { + return + } + + setError(null) + setSuccess(null) + + if (!token.trim()) { + setError("Add restaurant auth token before uploading.") + return + } + + const localPreview = URL.createObjectURL(file) + setPreviewUrl(localPreview) + + setUploading(true) + try { + const url = await uploadFoodImage(token.trim(), file) + setForm((prev) => ({ ...prev, image_url: url })) + setSuccess("Image uploaded.") + } catch (err) { + const message = err instanceof Error ? err.message : "Image upload failed" + setError(message) + } finally { + setUploading(false) + } + } + + const onEdit = (item: FoodItem) => { + setEditingId(item.id) + setForm(mapFoodToForm(item)) + setPreviewUrl(item.image_url) + setError(null) + setSuccess(null) + } + + const onDelete = async (item: FoodItem) => { + if (!token.trim()) { + setError("Add restaurant auth token before deleting.") + return + } + + setError(null) + setSuccess(null) + try { + await deleteFood(token.trim(), item.id) + setFoods((prev) => prev.filter((entry) => entry.id !== item.id)) + setSuccess("Food item removed.") + } catch (err) { + const message = err instanceof Error ? err.message : "Delete failed" + setError(message) + } + } + + const clearForm = () => { + setForm(EMPTY_FORM) + setEditingId(null) + setPreviewUrl("") + setError(null) + setSuccess(null) + } + + return ( +
+ + +
+
+
+

Overview

+

Menu Builder

+
+
+ + Wallet Connected +
+
+ +
+ + setToken(event.target.value)} + /> +
+ +
+
+

{isEditing ? "Edit food item" : "Add food item"}

+
void onSubmit(event)}> +
+ + setForm((prev) => ({ ...prev, name: event.target.value }))} + placeholder="Spicy Chicken Burger" + required + /> +
+ +
+ + setForm((prev) => ({ ...prev, price: event.target.value }))} + placeholder="12.99" + required + /> +
+ +
+ +