diff --git a/README.md b/README.md index 394fb94..0b245b0 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ This app is no longer a static demo. It now uses the backend API for authenticat | `/barcode` | Scan a barcode and search the inventory API for matches. | | `/users` | Site-admin users page scaffold for loading all users once the backend endpoint is available. | -Routing is defined in `src/App.jsx`. +Routing is defined in `src/App.tsx`. ## API Endpoint Map -### Dashboard (`src/pages/HomePage/HomePage.jsx`) +### Dashboard (`src/pages/HomePage/HomePage.tsx`) When signed out: @@ -42,7 +42,7 @@ When signed in: - `GET /api/locations` - `GET /api/inventoryitems` -### Inventory (`src/pages/InventoryPage/InventoryPage.jsx`) +### Inventory (`src/pages/InventoryPage/InventoryPage.tsx`) Locations: @@ -59,7 +59,7 @@ Inventory items: - `PUT /api/inventoryitems/{id}` - `DELETE /api/inventoryitems/{id}` -### Admin (`src/pages/AdminPage/AdminPage.jsx`) +### Admin (`src/pages/AdminPage/AdminPage.tsx`) Households: @@ -79,7 +79,7 @@ Notes: - User creation on this page uses the register contract: `email`, `password`, `confirmPassword`, `firstName`, and `lastName`. - The register endpoint does not assign roles, so new users are created without admin access by default. -### Search (`src/pages/SearchPage/SearchPage.jsx`) +### Search (`src/pages/SearchPage/SearchPage.tsx`) Initial page data: @@ -96,13 +96,13 @@ Note: - The backend search API only supports the `q` query parameter. - Location, amount, expiry-date filtering, and pagination are applied client-side after the API search returns. -### Barcode (`src/pages/BarcodePage/BarcodePage.jsx`) +### Barcode (`src/pages/BarcodePage/BarcodePage.tsx`) - `GET /api/search/items?q={barcode}` The barcode page scans via Quagga2, then searches the backend for inventory items whose barcode or other searchable fields match the scanned value. -### Users (`src/pages/UsersPage/UsersPage.jsx`) +### Users (`src/pages/UsersPage/UsersPage.tsx`) - `GET /api/users` @@ -111,7 +111,7 @@ Notes: - The users page is intentionally minimal and is ready to consume a future users endpoint. - The current C# repo does not expose `GET /api/users` yet, so the page shows a placeholder when that endpoint returns `404`. -### Shared Auth Support (`src/api/client.js`) +### Shared Auth Support (`src/api/client.ts`) The shared API client also supports: @@ -124,7 +124,7 @@ Notes: ## API Configuration -The API base URL is configured in `src/api/client.js`. +The API base URL is configured in `src/api/client.ts`. Current setup: @@ -157,8 +157,8 @@ After changing the env file, restart the Vite dev server. Auth/session logic lives in: -- `src/api/client.js` -- `src/context/AuthContext.jsx` +- `src/api/client.ts` +- `src/context/AuthContext.tsx` Behavior: @@ -268,15 +268,15 @@ Use `/users` to: | File | Purpose | | --- | --- | -| `src/api/client.js` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers | -| `src/context/AuthContext.jsx` | Shared auth/session state for the React app | -| `src/App.jsx` | Route definitions | -| `src/pages/AdminPage/AdminPage.jsx` | Household administration UI | -| `src/pages/HomePage/HomePage.jsx` | Dashboard and login UI | -| `src/pages/InventoryPage/InventoryPage.jsx` | Location and inventory CRUD UI | -| `src/pages/SearchPage/SearchPage.jsx` | API-backed search UI | -| `src/pages/BarcodePage/BarcodePage.jsx` | Barcode scanning and API lookup UI | -| `src/pages/UsersPage/UsersPage.jsx` | Users endpoint scaffold and list view | +| `src/api/client.ts` | API base URL, fetch wrapper, auth headers, token refresh, endpoint helpers | +| `src/context/AuthContext.tsx` | Shared auth/session state for the React app | +| `src/App.tsx` | Route definitions | +| `src/pages/AdminPage/AdminPage.tsx` | Household administration UI | +| `src/pages/HomePage/HomePage.tsx` | Dashboard and login UI | +| `src/pages/InventoryPage/InventoryPage.tsx` | Location and inventory CRUD UI | +| `src/pages/SearchPage/SearchPage.tsx` | API-backed search UI | +| `src/pages/BarcodePage/BarcodePage.tsx` | Barcode scanning and API lookup UI | +| `src/pages/UsersPage/UsersPage.tsx` | Users endpoint scaffold and list view | ## Troubleshooting diff --git a/index.html b/index.html index ecd23fe..18983d0 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,6 @@
- + diff --git a/package-lock.json b/package-lock.json index 94838d4..a0f44a4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,11 @@ "react-router-dom": "^7.13.2" }, "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.3", "vite": "^8.0.3" } }, @@ -908,6 +912,36 @@ "license": "MIT", "optional": true }, + "node_modules/@types/node": { + "version": "25.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz", + "integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@types/react": { + "version": "19.2.15", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.15.tgz", + "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", @@ -947,6 +981,13 @@ "url": "https://opencollective.com/express" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cwise-compiler": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz", @@ -1598,6 +1639,27 @@ "license": "0BSD", "optional": true }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + }, "node_modules/uniq": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", diff --git a/package.json b/package.json index d2f3874..26e732f 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,12 @@ { "name": "webpage-playground", "version": "1.0.0", - "description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with vanilla HTML, CSS, and JavaScript.", + "description": "A modern inventory management demo app featuring multiple pages for browsing, searching, and scanning barcodes. Built with React, TypeScript, and CSS.", "type": "module", "scripts": { "dev": "vite", - "build": "vite build", + "build": "tsc --noEmit && vite build", + "typecheck": "tsc --noEmit", "preview": "vite preview" }, "repository": { @@ -22,7 +23,11 @@ "react-router-dom": "^7.13.2" }, "devDependencies": { + "@types/node": "^25.9.1", + "@types/react": "^19.2.15", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "typescript": "^6.0.3", "vite": "^8.0.3" } } diff --git a/src/App.jsx b/src/App.tsx similarity index 77% rename from src/App.jsx rename to src/App.tsx index cb545da..67946fd 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -1,20 +1,21 @@ import { useEffect, useState } from 'react' import { Navigate, Routes, Route } from 'react-router-dom' -import Navbar from './components/Navbar/Navbar.jsx' -import AdminPage from './pages/AdminPage/AdminPage.jsx' -import HomePage from './pages/HomePage/HomePage.jsx' -import InventoryPage from './pages/InventoryPage/InventoryPage.jsx' -import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage.jsx' -import ProfilePage from './pages/ProfilePage/ProfilePage.jsx' -import SearchPage from './pages/SearchPage/SearchPage.jsx' -import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage.jsx' -import BarcodePage from './pages/BarcodePage/BarcodePage.jsx' -import UsersPage from './pages/UsersPage/UsersPage.jsx' -import { useAuth } from './context/AuthContext.jsx' +import Navbar from './components/Navbar/Navbar' +import AdminPage from './pages/AdminPage/AdminPage' +import HomePage from './pages/HomePage/HomePage' +import InventoryPage from './pages/InventoryPage/InventoryPage' +import MealPlannersPage from './pages/MealPlannersPage/MealPlannersPage' +import ProfilePage from './pages/ProfilePage/ProfilePage' +import SearchPage from './pages/SearchPage/SearchPage' +import ShoppingListsPage from './pages/ShoppingListsPage/ShoppingListsPage' +import BarcodePage from './pages/BarcodePage/BarcodePage' +import UsersPage from './pages/UsersPage/UsersPage' +import { useAuth } from './context/AuthContext' const THEME_STORAGE_KEY = 'pantry-theme' +export type Theme = 'light' | 'dark' -function resolveInitialTheme() { +function resolveInitialTheme(): Theme { if (typeof window === 'undefined') { return 'light' } @@ -40,7 +41,7 @@ if (typeof document !== 'undefined') { function App() { const { isAuthenticated, isSiteAdmin } = useAuth() - const [theme, setTheme] = useState(initialTheme) + const [theme, setTheme] = useState(initialTheme) useEffect(() => { document.documentElement.dataset.theme = theme diff --git a/src/api/client.js b/src/api/client.js deleted file mode 100644 index d292d6b..0000000 --- a/src/api/client.js +++ /dev/null @@ -1,487 +0,0 @@ -const SESSION_STORAGE_KEY = 'pantry-management-session' -const SESSION_CHANGE_EVENT = 'pantry-management-session-change' -const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '') - -let refreshPromise = null - -export class ApiError extends Error { - constructor(message, status = 0, data = null) { - super(message) - this.name = 'ApiError' - this.status = status - this.data = data - } -} - -function buildUrl(path) { - return API_BASE_URL ? `${API_BASE_URL}${path}` : path -} - -function parseStoredSession(rawValue) { - if (!rawValue) return null - - try { - return JSON.parse(rawValue) - } catch { - return null - } -} - -function dispatchSessionChange(session) { - if (typeof window === 'undefined') return - - window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, { - detail: session, - })) -} - -export function getStoredSession() { - if (typeof window === 'undefined') return null - - return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) -} - -export function saveSession(session) { - if (typeof window === 'undefined') return session - - window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) - dispatchSessionChange(session) - return session -} - -export function clearSession() { - if (typeof window === 'undefined') return - - window.localStorage.removeItem(SESSION_STORAGE_KEY) - dispatchSessionChange(null) -} - -export function subscribeToSessionChanges(listener) { - if (typeof window === 'undefined') return () => {} - - function handleCustomEvent(event) { - listener(event.detail ?? null) - } - - function handleStorageEvent(event) { - if (event.key !== SESSION_STORAGE_KEY) return - listener(parseStoredSession(event.newValue)) - } - - window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) - window.addEventListener('storage', handleStorageEvent) - - return () => { - window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) - window.removeEventListener('storage', handleStorageEvent) - } -} - -async function readResponse(response) { - const text = await response.text() - - if (!text) return null - - const contentType = response.headers.get('content-type') ?? '' - - if (contentType.includes('json')) { - try { - return JSON.parse(text) - } catch { - return text - } - } - - try { - return JSON.parse(text) - } catch { - return text - } -} - -function extractErrorMessage(data, fallbackMessage) { - if (!data) return fallbackMessage - - if (typeof data === 'string' && data.trim()) { - return data - } - - if (typeof data.message === 'string' && data.message.trim()) { - return data.message - } - - if (typeof data.Message === 'string' && data.Message.trim()) { - return data.Message - } - - if (typeof data.error === 'string' && data.error.trim()) { - return data.error - } - - if (data.errors && typeof data.errors === 'object') { - const messages = Object.values(data.errors) - .flatMap(value => Array.isArray(value) ? value : [value]) - .filter(Boolean) - - if (messages.length > 0) { - return messages.join(' ') - } - } - - if (typeof data.title === 'string' && data.title.trim()) { - return data.title - } - - return fallbackMessage -} - -async function performRefresh(session) { - if (!session?.refreshToken) { - clearSession() - throw new ApiError('Your session expired. Sign in again.', 401) - } - - let response - - try { - response = await fetch(buildUrl('/api/auth/refresh-token'), { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - accessToken: session.accessToken, - refreshToken: session.refreshToken, - }), - }) - } catch (error) { - throw new ApiError( - 'Unable to refresh your session. Make sure the API is running and reachable.', - 0, - error, - ) - } - - const data = await readResponse(response) - - if (!response.ok) { - clearSession() - throw new ApiError( - extractErrorMessage(data, 'Your session expired. Sign in again.'), - response.status, - data, - ) - } - - return saveSession({ - accessToken: data.accessToken, - refreshToken: data.refreshToken, - user: data.user ?? session.user ?? null, - }) -} - -async function refreshSession() { - if (!refreshPromise) { - refreshPromise = performRefresh(getStoredSession()) - .finally(() => { - refreshPromise = null - }) - } - - return refreshPromise -} - -async function requestJson(path, options = {}) { - const { - method = 'GET', - body, - headers = {}, - skipAuth = false, - retryOnAuthFailure = true, - } = options - - const session = getStoredSession() - const requestHeaders = { - Accept: 'application/json', - ...headers, - } - - if (body !== undefined) { - requestHeaders['Content-Type'] = 'application/json' - } - - if (!skipAuth && session?.accessToken) { - requestHeaders.Authorization = `Bearer ${session.accessToken}` - } - - let response - - try { - response = await fetch(buildUrl(path), { - method, - headers: requestHeaders, - body: body === undefined ? undefined : JSON.stringify(body), - }) - } catch (error) { - throw new ApiError( - 'Unable to reach the API. Make sure the backend is running and the certificate is trusted.', - 0, - error, - ) - } - - const data = await readResponse(response) - - if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) { - await refreshSession() - return requestJson(path, { ...options, retryOnAuthFailure: false }) - } - - if (!response.ok) { - throw new ApiError( - extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`), - response.status, - data, - ) - } - - return data -} - -export const authApi = { - async register(payload) { - return requestJson('/api/auth/register', { - method: 'POST', - body: payload, - skipAuth: true, - }) - }, - - async login(payload) { - const data = await requestJson('/api/auth/login', { - method: 'POST', - body: payload, - skipAuth: true, - }) - - saveSession({ - accessToken: data.accessToken, - refreshToken: data.refreshToken, - user: data.user ?? null, - }) - - return data - }, - - async logout() { - try { - await requestJson('/api/auth/logout', { - method: 'POST', - }) - } finally { - clearSession() - } - }, -} - -export const profileApi = { - getProfile() { - return requestJson('/api/profile') - }, - - getProtectedData() { - return requestJson('/api/profile/data') - }, - - updateProfile(payload) { - return requestJson('/api/profile', { - method: 'PUT', - body: payload, - }) - }, -} - -export const locationsApi = { - getLocations() { - return requestJson('/api/locations') - }, - - getLocationHistory(id) { - return requestJson(`/api/locations/${id}/history`) - }, - - createLocation(payload) { - return requestJson('/api/locations', { - method: 'POST', - body: payload, - }) - }, - - updateLocation(id, payload) { - return requestJson(`/api/locations/${id}`, { - method: 'PUT', - body: payload, - }) - }, - - deleteLocation(id) { - return requestJson(`/api/locations/${id}`, { - method: 'DELETE', - }) - }, -} - -export const inventoryApi = { - getInventoryItems() { - return requestJson('/api/inventoryitems') - }, - - getInventoryItem(id) { - return requestJson(`/api/inventoryitems/${id}`) - }, - - createInventoryItem(payload) { - return requestJson('/api/inventoryitems', { - method: 'POST', - body: payload, - }) - }, - - updateInventoryItem(id, payload) { - return requestJson(`/api/inventoryitems/${id}`, { - method: 'PUT', - body: payload, - }) - }, - - deleteInventoryItem(id) { - return requestJson(`/api/inventoryitems/${id}`, { - method: 'DELETE', - }) - }, -} - -export const searchApi = { - searchLocations(query) { - return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`) - }, - - searchItems(query) { - return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`) - }, -} - -export const householdsApi = { - getHouseholds() { - return requestJson('/api/households') - }, - - getHouseholdHistory(id) { - return requestJson(`/api/households/${id}/history`) - }, - - createHousehold(payload) { - return requestJson('/api/households', { - method: 'POST', - body: payload, - }) - }, - - updateHousehold(id, payload) { - return requestJson(`/api/households/${id}`, { - method: 'PUT', - body: payload, - }) - }, - - inviteHouseholdMember(id, payload) { - return requestJson(`/api/households/${id}/invite`, { - method: 'POST', - body: payload, - }) - }, - - leaveHousehold(id) { - return requestJson(`/api/households/${id}/leave`, { - method: 'DELETE', - }) - }, -} - -export const usersApi = { - getUsers() { - return requestJson('/api/users') - }, - - getUser(id) { - return requestJson(`/api/users/${id}`) - }, - - updateUser(id, payload) { - return requestJson(`/api/users/${id}`, { - method: 'PUT', - body: payload, - }) - }, -} - -export const shoppingListsApi = { - getShoppingLists() { - return requestJson('/api/shoppinglists') - }, - - getShoppingList(id) { - return requestJson(`/api/shoppinglists/${id}`) - }, - - createShoppingList(payload) { - return requestJson('/api/shoppinglists', { - method: 'POST', - body: payload, - }) - }, - - updateShoppingList(id, payload) { - return requestJson(`/api/shoppinglists/${id}`, { - method: 'PUT', - body: payload, - }) - }, - - deleteShoppingList(id) { - return requestJson(`/api/shoppinglists/${id}`, { - method: 'DELETE', - }) - }, -} - -export const mealPlannersApi = { - getMealPlanners() { - return requestJson('/api/mealplanners') - }, - - getMealPlanner(id) { - return requestJson(`/api/mealplanners/${id}`) - }, - - createMealPlanner(payload) { - return requestJson('/api/mealplanners', { - method: 'POST', - body: payload, - }) - }, - - updateMealPlanner(id, payload) { - return requestJson(`/api/mealplanners/${id}`, { - method: 'PUT', - body: payload, - }) - }, - - deleteMealPlanner(id) { - return requestJson(`/api/mealplanners/${id}`, { - method: 'DELETE', - }) - }, -} diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..b45304a --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,531 @@ +import type { + AuthCredentials, + AuthResponse, + AuthSession, + EntityId, + Household, + HouseholdHistoryEntry, + HouseholdInvitePayload, + HouseholdPayload, + InventoryItem, + InventoryItemPayload, + Location, + LocationHistoryEntry, + LocationPayload, + MealPlanner, + MealPlannerPayload, + ProfileUpdatePayload, + RegisterPayload, + ShoppingList, + ShoppingListPayload, + User, +} from '../types/models' + +const SESSION_STORAGE_KEY = 'pantry-management-session' +const SESSION_CHANGE_EVENT = 'pantry-management-session-change' +const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? 'https://api.pantrymanager.kitchen/').trim().replace(/\/$/, '') + +let refreshPromise: Promise | null = null + +export class ApiError extends Error { + status: number + data: unknown + + constructor(message: string, status = 0, data: unknown = null) { + super(message) + this.name = 'ApiError' + this.status = status + this.data = data + } +} + +interface RequestOptions { + method?: string + body?: unknown + headers?: Record + skipAuth?: boolean + retryOnAuthFailure?: boolean +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function buildUrl(path: string): string { + return API_BASE_URL ? `${API_BASE_URL}${path}` : path +} + +function parseStoredSession(rawValue: string | null): AuthSession | null { + if (!rawValue) return null + + try { + return JSON.parse(rawValue) as AuthSession + } catch { + return null + } +} + +function dispatchSessionChange(session: AuthSession | null): void { + if (typeof window === 'undefined') return + + window.dispatchEvent(new CustomEvent(SESSION_CHANGE_EVENT, { + detail: session, + })) +} + +export function getStoredSession(): AuthSession | null { + if (typeof window === 'undefined') return null + + return parseStoredSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) +} + +export function saveSession(session: AuthSession): AuthSession { + if (typeof window === 'undefined') return session + + window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)) + dispatchSessionChange(session) + return session +} + +export function clearSession(): void { + if (typeof window === 'undefined') return + + window.localStorage.removeItem(SESSION_STORAGE_KEY) + dispatchSessionChange(null) +} + +export function subscribeToSessionChanges(listener: (session: AuthSession | null) => void): () => void { + if (typeof window === 'undefined') return () => {} + + function handleCustomEvent(event: Event) { + listener((event as CustomEvent).detail ?? null) + } + + function handleStorageEvent(event: StorageEvent) { + if (event.key !== SESSION_STORAGE_KEY) return + listener(parseStoredSession(event.newValue)) + } + + window.addEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) + window.addEventListener('storage', handleStorageEvent) + + return () => { + window.removeEventListener(SESSION_CHANGE_EVENT, handleCustomEvent) + window.removeEventListener('storage', handleStorageEvent) + } +} + +async function readResponse(response: Response): Promise { + const text = await response.text() + + if (!text) return null + + const contentType = response.headers.get('content-type') ?? '' + + if (contentType.includes('json')) { + try { + return JSON.parse(text) + } catch { + return text + } + } + + try { + return JSON.parse(text) + } catch { + return text + } +} + +function extractErrorMessage(data: unknown, fallbackMessage: string): string { + if (!data) return fallbackMessage + + if (typeof data === 'string' && data.trim()) { + return data + } + + if (!isRecord(data)) { + return fallbackMessage + } + + if (typeof data.message === 'string' && data.message.trim()) { + return data.message + } + + if (typeof data.Message === 'string' && data.Message.trim()) { + return data.Message + } + + if (typeof data.error === 'string' && data.error.trim()) { + return data.error + } + + if (data.errors && typeof data.errors === 'object') { + const messages = Object.values(data.errors) + .flatMap(value => Array.isArray(value) ? value : [value]) + .filter(Boolean) + + if (messages.length > 0) { + return messages.map(String).join(' ') + } + } + + if (typeof data.title === 'string' && data.title.trim()) { + return data.title + } + + return fallbackMessage +} + +async function performRefresh(session: AuthSession | null): Promise { + if (!session?.refreshToken) { + clearSession() + throw new ApiError('Your session expired. Sign in again.', 401) + } + + let response + + try { + response = await fetch(buildUrl('/api/auth/refresh-token'), { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + accessToken: session.accessToken, + refreshToken: session.refreshToken, + }), + }) + } catch (error) { + throw new ApiError( + 'Unable to refresh your session. Make sure the API is running and reachable.', + 0, + error, + ) + } + + const data = await readResponse(response) + + if (!response.ok) { + clearSession() + throw new ApiError( + extractErrorMessage(data, 'Your session expired. Sign in again.'), + response.status, + data, + ) + } + + const refreshData = data as Partial + + return saveSession({ + accessToken: refreshData.accessToken ?? '', + refreshToken: refreshData.refreshToken ?? '', + user: refreshData.user ?? session.user ?? null, + }) +} + +async function refreshSession(): Promise { + if (!refreshPromise) { + refreshPromise = performRefresh(getStoredSession()) + .finally(() => { + refreshPromise = null + }) + } + + return refreshPromise +} + +async function requestJson(path: string, options: RequestOptions = {}): Promise { + const { + method = 'GET', + body, + headers = {}, + skipAuth = false, + retryOnAuthFailure = true, + } = options + + const session = getStoredSession() + const requestHeaders: Record = { + Accept: 'application/json', + ...headers, + } + + if (body !== undefined) { + requestHeaders['Content-Type'] = 'application/json' + } + + if (!skipAuth && session?.accessToken) { + requestHeaders.Authorization = `Bearer ${session.accessToken}` + } + + let response + + try { + response = await fetch(buildUrl(path), { + method, + headers: requestHeaders, + body: body === undefined ? undefined : JSON.stringify(body), + }) + } catch (error) { + throw new ApiError( + 'Unable to reach the API. Make sure the backend is running and the certificate is trusted.', + 0, + error, + ) + } + + const data = await readResponse(response) + + if (response.status === 401 && !skipAuth && retryOnAuthFailure && session?.refreshToken) { + await refreshSession() + return requestJson(path, { ...options, retryOnAuthFailure: false }) + } + + if (!response.ok) { + throw new ApiError( + extractErrorMessage(data, `${method} ${path} failed with status ${response.status}.`), + response.status, + data, + ) + } + + return data as T +} + +export const authApi = { + async register(payload: RegisterPayload): Promise { + return requestJson('/api/auth/register', { + method: 'POST', + body: payload, + skipAuth: true, + }) + }, + + async login(payload: AuthCredentials): Promise { + const data = await requestJson('/api/auth/login', { + method: 'POST', + body: payload, + skipAuth: true, + }) + + saveSession({ + accessToken: data.accessToken, + refreshToken: data.refreshToken, + user: data.user ?? null, + }) + + return data + }, + + async logout(): Promise { + try { + await requestJson('/api/auth/logout', { + method: 'POST', + }) + } finally { + clearSession() + } + }, +} + +export const profileApi = { + getProfile(): Promise { + return requestJson('/api/profile') + }, + + getProtectedData(): Promise { + return requestJson('/api/profile/data') + }, + + updateProfile(payload: ProfileUpdatePayload): Promise { + return requestJson('/api/profile', { + method: 'PUT', + body: payload, + }) + }, +} + +export const locationsApi = { + getLocations(): Promise { + return requestJson('/api/locations') + }, + + getLocationHistory(id: EntityId): Promise { + return requestJson(`/api/locations/${id}/history`) + }, + + createLocation(payload: LocationPayload): Promise { + return requestJson('/api/locations', { + method: 'POST', + body: payload, + }) + }, + + updateLocation(id: EntityId, payload: LocationPayload): Promise { + return requestJson(`/api/locations/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteLocation(id: EntityId): Promise { + return requestJson(`/api/locations/${id}`, { + method: 'DELETE', + }) + }, +} + +export const inventoryApi = { + getInventoryItems(): Promise { + return requestJson('/api/inventoryitems') + }, + + getInventoryItem(id: EntityId): Promise { + return requestJson(`/api/inventoryitems/${id}`) + }, + + createInventoryItem(payload: InventoryItemPayload): Promise { + return requestJson('/api/inventoryitems', { + method: 'POST', + body: payload, + }) + }, + + updateInventoryItem(id: EntityId, payload: InventoryItemPayload): Promise { + return requestJson(`/api/inventoryitems/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteInventoryItem(id: EntityId): Promise { + return requestJson(`/api/inventoryitems/${id}`, { + method: 'DELETE', + }) + }, +} + +export const searchApi = { + searchLocations(query: string): Promise { + return requestJson(`/api/search/locations?q=${encodeURIComponent(query)}`) + }, + + searchItems(query: string): Promise { + return requestJson(`/api/search/items?q=${encodeURIComponent(query)}`) + }, +} + +export const householdsApi = { + getHouseholds(): Promise { + return requestJson('/api/households') + }, + + getHouseholdHistory(id: EntityId): Promise { + return requestJson(`/api/households/${id}/history`) + }, + + createHousehold(payload: HouseholdPayload): Promise { + return requestJson('/api/households', { + method: 'POST', + body: payload, + }) + }, + + updateHousehold(id: EntityId, payload: HouseholdPayload): Promise { + return requestJson(`/api/households/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + inviteHouseholdMember(id: EntityId, payload: HouseholdInvitePayload): Promise { + return requestJson(`/api/households/${id}/invite`, { + method: 'POST', + body: payload, + }) + }, + + leaveHousehold(id: EntityId): Promise { + return requestJson(`/api/households/${id}/leave`, { + method: 'DELETE', + }) + }, +} + +export const usersApi = { + getUsers(): Promise { + return requestJson('/api/users') + }, + + getUser(id: EntityId): Promise { + return requestJson(`/api/users/${id}`) + }, + + updateUser(id: EntityId, payload: Partial & { roles?: string[] }): Promise { + return requestJson(`/api/users/${id}`, { + method: 'PUT', + body: payload, + }) + }, +} + +export const shoppingListsApi = { + getShoppingLists(): Promise { + return requestJson('/api/shoppinglists') + }, + + getShoppingList(id: EntityId): Promise { + return requestJson(`/api/shoppinglists/${id}`) + }, + + createShoppingList(payload: ShoppingListPayload): Promise { + return requestJson('/api/shoppinglists', { + method: 'POST', + body: payload, + }) + }, + + updateShoppingList(id: EntityId, payload: ShoppingListPayload): Promise { + return requestJson(`/api/shoppinglists/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteShoppingList(id: EntityId): Promise { + return requestJson(`/api/shoppinglists/${id}`, { + method: 'DELETE', + }) + }, +} + +export const mealPlannersApi = { + getMealPlanners(): Promise { + return requestJson('/api/mealplanners') + }, + + getMealPlanner(id: EntityId): Promise { + return requestJson(`/api/mealplanners/${id}`) + }, + + createMealPlanner(payload: MealPlannerPayload): Promise { + return requestJson('/api/mealplanners', { + method: 'POST', + body: payload, + }) + }, + + updateMealPlanner(id: EntityId, payload: MealPlannerPayload): Promise { + return requestJson(`/api/mealplanners/${id}`, { + method: 'PUT', + body: payload, + }) + }, + + deleteMealPlanner(id: EntityId): Promise { + return requestJson(`/api/mealplanners/${id}`, { + method: 'DELETE', + }) + }, +} diff --git a/src/components/ItemCard/ItemCard.jsx b/src/components/ItemCard/ItemCard.tsx similarity index 71% rename from src/components/ItemCard/ItemCard.jsx rename to src/components/ItemCard/ItemCard.tsx index 978d9f8..c30418c 100644 --- a/src/components/ItemCard/ItemCard.jsx +++ b/src/components/ItemCard/ItemCard.tsx @@ -1,6 +1,29 @@ +import type { ReactNode } from 'react' import './ItemCard.css' -function ItemCard({ imgSrc = '', imgAlt = '', text1 = '', text2 = '', text3 = '', editable = false, imgWidth = 80, imgHeight = 80, children }) { +interface ItemCardProps { + imgSrc?: string + imgAlt?: string + text1?: string + text2?: string + text3?: string + editable?: boolean + imgWidth?: number + imgHeight?: number + children?: ReactNode +} + +function ItemCard({ + imgSrc = '', + imgAlt = '', + text1 = '', + text2 = '', + text3 = '', + editable = false, + imgWidth = 80, + imgHeight = 80, + children, +}: ItemCardProps) { return (
diff --git a/src/components/Navbar/Navbar.jsx b/src/components/Navbar/Navbar.tsx similarity index 92% rename from src/components/Navbar/Navbar.jsx rename to src/components/Navbar/Navbar.tsx index 97afbe5..9affd5e 100644 --- a/src/components/Navbar/Navbar.jsx +++ b/src/components/Navbar/Navbar.tsx @@ -1,11 +1,18 @@ import { useEffect, useState } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { useAuth } from '../../context/AuthContext.jsx' +import { useAuth } from '../../context/AuthContext' +import type { Theme } from '../../App' +import type { User } from '../../types/models' import './Navbar.css' const MOBILE_NAV_BREAKPOINT = 760 -function formatUserDisplayName(user) { +interface NavbarProps { + theme: Theme + onToggleTheme: () => void +} + +function formatUserDisplayName(user: User | null): string { const fullName = [user?.firstName, user?.lastName] .filter(part => typeof part === 'string' && part.trim()) .join(' ') @@ -30,7 +37,7 @@ function MoonIcon() { ) } -function Navbar({ theme, onToggleTheme }) { +function Navbar({ theme, onToggleTheme }: NavbarProps) { const { isAuthenticated, isSiteAdmin, logout, user } = useAuth() const location = useLocation() const [isMenuOpen, setIsMenuOpen] = useState(false) @@ -115,7 +122,7 @@ function Navbar({ theme, onToggleTheme }) { {isAuthenticated && ( diff --git a/src/components/PieChart/PieChart.jsx b/src/components/PieChart/PieChart.tsx similarity index 81% rename from src/components/PieChart/PieChart.jsx rename to src/components/PieChart/PieChart.tsx index 761320b..38f8718 100644 --- a/src/components/PieChart/PieChart.jsx +++ b/src/components/PieChart/PieChart.tsx @@ -4,7 +4,22 @@ import './PieChart.css' const RADIUS = 10 const CIRCUMFERENCE = 2 * Math.PI * RADIUS -function PieChart({ segments }) { +export interface PieSegment { + label: string + value: number + color: string +} + +interface ComputedPieSegment extends PieSegment { + segmentLength: number + offset: number +} + +interface PieChartProps { + segments: PieSegment[] +} + +function PieChart({ segments }: PieChartProps) { const computedSegments = useMemo(() => { const total = segments.reduce((acc, seg) => acc + seg.value, 0) let currentOffset = 0 @@ -15,7 +30,7 @@ function PieChart({ segments }) { currentOffset += segmentLength return { ...seg, segmentLength, offset } }) - }, [segments]) + }, [segments]) satisfies ComputedPieSegment[] return (
diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.tsx similarity index 69% rename from src/context/AuthContext.jsx rename to src/context/AuthContext.tsx index 6e80679..71ec40c 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.tsx @@ -1,20 +1,38 @@ -import { createContext, useContext, useEffect, useState } from 'react' +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' import { authApi, getStoredSession, profileApi, saveSession, subscribeToSessionChanges, -} from '../api/client.js' +} from '../api/client' +import type { AuthCredentials, AuthResponse, AuthSession, RegisterPayload, User } from '../types/models' -const AuthContext = createContext(null) +interface AuthContextValue { + session: AuthSession | null + user: User | null + isAuthenticated: boolean + isSiteAdmin: boolean + initializing: boolean + login: (payload: AuthCredentials) => Promise + register: (payload: RegisterPayload) => Promise + logout: () => Promise + refreshProfile: () => Promise + setCurrentUserProfile: (profile: User) => AuthSession | null +} + +interface AuthProviderProps { + children: ReactNode +} + +const AuthContext = createContext(null) const SITE_ADMIN_ROLES = new Set(['Site Admin', 'Admin']) -export function AuthProvider({ children }) { +export function AuthProvider({ children }: AuthProviderProps) { const [session, setSession] = useState(() => getStoredSession()) const [initializing, setInitializing] = useState(() => Boolean(getStoredSession())) - function setCurrentUserProfile(profile) { + function setCurrentUserProfile(profile: User): AuthSession | null { const currentSession = getStoredSession() if (!currentSession) { @@ -27,7 +45,7 @@ export function AuthProvider({ children }) { }) } - async function refreshProfile() { + async function refreshProfile(): Promise { const currentSession = getStoredSession() if (!currentSession) { diff --git a/src/data/inventory.js b/src/data/inventory.ts similarity index 95% rename from src/data/inventory.js rename to src/data/inventory.ts index 9b4e034..ea0952b 100644 --- a/src/data/inventory.js +++ b/src/data/inventory.ts @@ -1,10 +1,12 @@ -function getDate(daysFromNow) { +import type { DemoInventoryItem } from '../types/models' + +function getDate(daysFromNow: number): string { const date = new Date() date.setDate(date.getDate() + daysFromNow) return date.toISOString().split('T')[0] } -export const inventoryData = [ +export const inventoryData: DemoInventoryItem[] = [ // Pantry items { id: 1, name: 'Pasta', location: 'Pantry', quantity: 5, unit: 'boxes', expiryDate: getDate(120), img: 'https://picsum.photos/seed/pasta/200/200' }, { id: 2, name: 'Rice', location: 'Pantry', quantity: 3, unit: 'bags', expiryDate: getDate(180), img: 'https://picsum.photos/seed/rice/200/200' }, diff --git a/src/main.jsx b/src/main.tsx similarity index 55% rename from src/main.jsx rename to src/main.tsx index bf1d98d..b31dc4b 100644 --- a/src/main.jsx +++ b/src/main.tsx @@ -1,11 +1,17 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' -import App from './App.jsx' -import { AuthProvider } from './context/AuthContext.jsx' +import App from './App' +import { AuthProvider } from './context/AuthContext' import './App.css' -createRoot(document.getElementById('root')).render( +const rootElement = document.getElementById('root') + +if (!rootElement) { + throw new Error('Root element not found.') +} + +createRoot(rootElement).render( diff --git a/src/pages/AdminPage/AdminPage.jsx b/src/pages/AdminPage/AdminPage.tsx similarity index 94% rename from src/pages/AdminPage/AdminPage.jsx rename to src/pages/AdminPage/AdminPage.tsx index 83bdf7a..00f7591 100644 --- a/src/pages/AdminPage/AdminPage.jsx +++ b/src/pages/AdminPage/AdminPage.tsx @@ -1,15 +1,29 @@ -import { useEffect, useState } from 'react' -import { householdsApi, usersApi } from '../../api/client.js' -import { useAuth } from '../../context/AuthContext.jsx' -import { formatDate } from '../../utils/searchUtils.js' +import { useEffect, useState, type FormEvent } from 'react' +import { householdsApi, usersApi } from '../../api/client' +import { useAuth } from '../../context/AuthContext' +import { formatDate } from '../../utils/searchUtils' +import type { Household, HouseholdHistoryEntry, HouseholdMember, User } from '../../types/models' import './AdminPage.css' -const EMPTY_HOUSEHOLD_FORM = { +interface HouseholdForm { + name: string + description: string +} + +interface UserCreateForm { + firstName: string + lastName: string + email: string + password: string + confirmPassword: string +} + +const EMPTY_HOUSEHOLD_FORM: HouseholdForm = { name: '', description: '', } -const EMPTY_USER_FORM = { +const EMPTY_USER_FORM: UserCreateForm = { firstName: '', lastName: '', email: '', @@ -17,7 +31,7 @@ const EMPTY_USER_FORM = { confirmPassword: '', } -function formatPersonName(person) { +function formatPersonName(person: User | HouseholdMember): string { const fullName = [person.firstName, person.lastName] .filter(Boolean) .join(' ') @@ -30,18 +44,18 @@ function AdminPage() { const [loading, setLoading] = useState(false) const [errorMessage, setErrorMessage] = useState('') const [statusMessage, setStatusMessage] = useState('') - const [households, setHouseholds] = useState([]) + const [households, setHouseholds] = useState([]) const [selectedHouseholdId, setSelectedHouseholdId] = useState('') const [editingHouseholdId, setEditingHouseholdId] = useState('') - const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) + const [householdForm, setHouseholdForm] = useState(EMPTY_HOUSEHOLD_FORM) const [inviteEmail, setInviteEmail] = useState('') - const [userForm, setUserForm] = useState(EMPTY_USER_FORM) - const [createdUser, setCreatedUser] = useState(null) - const [householdHistory, setHouseholdHistory] = useState([]) + const [userForm, setUserForm] = useState(EMPTY_USER_FORM) + const [createdUser, setCreatedUser] = useState(null) + const [householdHistory, setHouseholdHistory] = useState([]) const [householdHistoryLoading, setHouseholdHistoryLoading] = useState(false) const [householdHistoryError, setHouseholdHistoryError] = useState('') - async function loadHouseholds(preferredSelectionId = '') { + async function loadHouseholds(preferredSelectionId = ''): Promise { const response = await householdsApi.getHouseholds() const nextHouseholds = Array.isArray(response) ? response : [] @@ -167,7 +181,7 @@ function AdminPage() { setUserForm(EMPTY_USER_FORM) } - async function handleHouseholdSubmit(event) { + async function handleHouseholdSubmit(event: FormEvent) { event.preventDefault() const name = householdForm.name.trim() @@ -208,7 +222,7 @@ function AdminPage() { } } - async function handleInviteSubmit(event) { + async function handleInviteSubmit(event: FormEvent) { event.preventDefault() const email = inviteEmail.trim() @@ -244,7 +258,7 @@ function AdminPage() { } } - async function handleUserSubmit(event) { + async function handleUserSubmit(event: FormEvent) { event.preventDefault() const email = userForm.email.trim() @@ -289,6 +303,7 @@ function AdminPage() { : null setCreatedUser(matchingUser ?? { + id: '', email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), @@ -296,6 +311,7 @@ function AdminPage() { }) } catch { setCreatedUser({ + id: '', email, firstName: userForm.firstName.trim(), lastName: userForm.lastName.trim(), @@ -312,7 +328,7 @@ function AdminPage() { } } - async function handleLeaveHousehold(householdId) { + async function handleLeaveHousehold(householdId: string) { const confirmed = window.confirm('Leave this household?') if (!confirmed) return @@ -405,7 +421,7 @@ function AdminPage() {