Initial commit
This commit is contained in:
2
.env.example
Normal file
2
.env.example
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
# Leave blank when the API is served from the same host or when using the Vite dev proxy.
|
||||||
|
VITE_API_BASE_URL=
|
||||||
1
.env.production
Normal file
1
.env.production
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_API_BASE_URL=https://api.pantrymanager.kitchen
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
*.local
|
||||||
|
*.log
|
||||||
|
.DS_Store
|
||||||
57
README.md
Normal file
57
README.md
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
# Keeply pantry manager
|
||||||
|
|
||||||
|
A responsive React and TypeScript frontend for the pantry manager API in `C:\Source\Programming Projects\pantry-manager-api-csharp`.
|
||||||
|
|
||||||
|
## Included
|
||||||
|
|
||||||
|
- JWT sign in, registration, session restore, and automatic token refresh
|
||||||
|
- Pantry-first grid with search, location/freshness filters, expiry summaries, and sorting
|
||||||
|
- Manual item CRUD and a barcode-first quick-add flow
|
||||||
|
- Camera scanning with a native `BarcodeDetector` path and a cross-browser ZXing fallback
|
||||||
|
- Location management
|
||||||
|
- Household management, member invites, and leave-household flow
|
||||||
|
- Site administrator user and role management
|
||||||
|
- Profile, email, name, and password settings
|
||||||
|
- Responsive desktop, tablet, and mobile layouts
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
1. Start the API at `http://localhost:5000`.
|
||||||
|
2. Install frontend dependencies:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Start the frontend:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Open `http://localhost:5173`.
|
||||||
|
|
||||||
|
Vite proxies `/api` requests to `http://localhost:5000` during local development.
|
||||||
|
|
||||||
|
## API URL
|
||||||
|
|
||||||
|
For a separately hosted API, copy `.env.example` to `.env` and set:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
VITE_API_BASE_URL=https://your-api.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Leave the value blank when the API is on the same origin or when using the local Vite proxy.
|
||||||
|
|
||||||
|
## Barcode scanning
|
||||||
|
|
||||||
|
Live camera detection uses the native `BarcodeDetector` browser API when available. Browsers without it, including Firefox, use the free, MIT-licensed `@zxing/browser` fallback. Both paths require a secure origin; `localhost` is treated as secure for local development. Manual barcode entry remains available if camera access is denied or unavailable.
|
||||||
|
|
||||||
|
The backend identifies the product while `POST /api/inventoryitems` is processed. The frontend therefore captures the barcode and expiry information first, then refreshes the inventory to show the API-resolved product.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
25
eslint.config.js
Normal file
25
eslint.config.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{ ignores: ['dist'] },
|
||||||
|
{
|
||||||
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
'react-hooks': reactHooks,
|
||||||
|
'react-refresh': reactRefresh,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...reactHooks.configs.recommended.rules,
|
||||||
|
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
23
index.html
Normal file
23
index.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#f7f7f2" />
|
||||||
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Keeply pantry manager — track food, expiry dates, locations and households."
|
||||||
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@500;600;700;800&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<title>Keeply — Pantry manager</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3401
package-lock.json
generated
Normal file
3401
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
32
package.json
Normal file
32
package.json
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"name": "pantry-manager-frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
|
"lucide-react": "^0.468.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0",
|
||||||
|
"react-router-dom": "^7.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.17.0",
|
||||||
|
"@types/react": "^19.0.3",
|
||||||
|
"@types/react-dom": "^19.0.2",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"eslint": "^9.17.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.16",
|
||||||
|
"globals": "^15.14.0",
|
||||||
|
"typescript": "~5.7.2",
|
||||||
|
"typescript-eslint": "^8.18.2",
|
||||||
|
"vite": "^6.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/App.tsx
Normal file
32
src/App.tsx
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Navigate, Route, Routes } from 'react-router-dom'
|
||||||
|
import { AppShell } from './components/AppShell'
|
||||||
|
import { FullPageLoader } from './components/ui'
|
||||||
|
import { useAuth } from './context/AuthContext'
|
||||||
|
import { AuthPage } from './pages/AuthPage'
|
||||||
|
import { HouseholdsPage } from './pages/HouseholdsPage'
|
||||||
|
import { InventoryPage } from './pages/InventoryPage'
|
||||||
|
import { LocationsPage } from './pages/LocationsPage'
|
||||||
|
import { ProfilePage } from './pages/ProfilePage'
|
||||||
|
import { UsersPage } from './pages/UsersPage'
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
|
||||||
|
if (loading) return <FullPageLoader />
|
||||||
|
if (!user) return <AuthPage />
|
||||||
|
|
||||||
|
const isSiteAdmin = user.roles.some((role) => role === 'Site Admin' || role === 'Admin')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route element={<AppShell />}>
|
||||||
|
<Route index element={<InventoryPage />} />
|
||||||
|
<Route path="locations" element={<LocationsPage />} />
|
||||||
|
<Route path="households" element={<HouseholdsPage />} />
|
||||||
|
<Route path="profile" element={<ProfilePage />} />
|
||||||
|
{isSiteAdmin && <Route path="users" element={<UsersPage />} />}
|
||||||
|
</Route>
|
||||||
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
|
</Routes>
|
||||||
|
)
|
||||||
|
}
|
||||||
116
src/components/AppShell.tsx
Normal file
116
src/components/AppShell.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
Boxes,
|
||||||
|
Building2,
|
||||||
|
ChevronRight,
|
||||||
|
LogOut,
|
||||||
|
MapPin,
|
||||||
|
Menu,
|
||||||
|
Settings,
|
||||||
|
UsersRound,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { displayName, getInitials } from '../lib/format'
|
||||||
|
import { Logo } from './Logo'
|
||||||
|
|
||||||
|
const navigation = [
|
||||||
|
{ to: '/', label: 'My pantry', icon: Boxes, end: true },
|
||||||
|
{ to: '/locations', label: 'Locations', icon: MapPin },
|
||||||
|
{ to: '/households', label: 'Households', icon: Building2 },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
const isSiteAdmin = user?.roles.some((role) => role === 'Site Admin' || role === 'Admin') ?? false
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await logout()
|
||||||
|
navigate('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const closeMobileNav = () => setMobileOpen(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app-shell">
|
||||||
|
<header className="mobile-header">
|
||||||
|
<button className="icon-button" onClick={() => setMobileOpen(true)} aria-label="Open navigation">
|
||||||
|
<Menu size={21} />
|
||||||
|
</button>
|
||||||
|
<Logo />
|
||||||
|
<NavLink className="avatar avatar--small" to="/profile" aria-label="Open profile">
|
||||||
|
{getInitials(user?.firstName, user?.lastName, user?.email)}
|
||||||
|
</NavLink>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{mobileOpen && <button className="sidebar-backdrop" onClick={closeMobileNav} aria-label="Close navigation" />}
|
||||||
|
<aside className={`sidebar ${mobileOpen ? 'sidebar--open' : ''}`}>
|
||||||
|
<div className="sidebar__top">
|
||||||
|
<Logo />
|
||||||
|
<button className="icon-button sidebar__close" onClick={closeMobileNav} aria-label="Close navigation">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="sidebar__nav" aria-label="Main navigation">
|
||||||
|
<p className="sidebar__eyebrow">Workspace</p>
|
||||||
|
{navigation.map(({ to, label, icon: Icon, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
onClick={closeMobileNav}
|
||||||
|
className={({ isActive }) => `nav-item ${isActive ? 'nav-item--active' : ''}`}
|
||||||
|
>
|
||||||
|
<Icon size={19} strokeWidth={2} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isSiteAdmin && (
|
||||||
|
<>
|
||||||
|
<p className="sidebar__eyebrow sidebar__eyebrow--spaced">Administration</p>
|
||||||
|
<NavLink
|
||||||
|
to="/users"
|
||||||
|
onClick={closeMobileNav}
|
||||||
|
className={({ isActive }) => `nav-item ${isActive ? 'nav-item--active' : ''}`}
|
||||||
|
>
|
||||||
|
<UsersRound size={19} strokeWidth={2} />
|
||||||
|
<span>Users</span>
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="sidebar__footer">
|
||||||
|
<NavLink className="profile-chip" to="/profile" onClick={closeMobileNav}>
|
||||||
|
<span className="avatar">{getInitials(user?.firstName, user?.lastName, user?.email)}</span>
|
||||||
|
<span className="profile-chip__copy">
|
||||||
|
<strong>{displayName(user?.firstName, user?.lastName, user?.email)}</strong>
|
||||||
|
<small>{isSiteAdmin ? 'Site administrator' : 'Pantry member'}</small>
|
||||||
|
</span>
|
||||||
|
<ChevronRight size={17} />
|
||||||
|
</NavLink>
|
||||||
|
<div className="sidebar__actions">
|
||||||
|
<NavLink className="sidebar-action" to="/profile" title="Settings">
|
||||||
|
<Settings size={18} />
|
||||||
|
<span>Settings</span>
|
||||||
|
</NavLink>
|
||||||
|
<button className="sidebar-action" onClick={() => void handleLogout()}>
|
||||||
|
<LogOut size={18} />
|
||||||
|
<span>Sign out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main className="app-main" key={location.pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
295
src/components/BarcodeScannerModal.tsx
Normal file
295
src/components/BarcodeScannerModal.tsx
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { ArrowLeft, Camera, CameraOff, CheckCircle2, ScanBarcode, Sparkles } from 'lucide-react'
|
||||||
|
import { FormEvent, useEffect, useRef, useState } from 'react'
|
||||||
|
import { ApiError, api } from '../lib/api'
|
||||||
|
import { toApiDate } from '../lib/format'
|
||||||
|
import type { BarcodeLookup, InventoryItemRequest, Location } from '../types'
|
||||||
|
import { Modal, Spinner } from './ui'
|
||||||
|
|
||||||
|
interface BarcodeScannerModalProps {
|
||||||
|
locations: Location[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (message: string) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
type ScannerStep = 'barcode' | 'dates' | 'details'
|
||||||
|
|
||||||
|
export function BarcodeScannerModal({ locations, onClose, onSaved }: BarcodeScannerModalProps) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const [step, setStep] = useState<ScannerStep>('barcode')
|
||||||
|
const [scanning, setScanning] = useState(false)
|
||||||
|
const [barcode, setBarcode] = useState('')
|
||||||
|
const [lookup, setLookup] = useState<BarcodeLookup | null>(null)
|
||||||
|
const [fallbackName, setFallbackName] = useState('')
|
||||||
|
const [expiryDate, setExpiryDate] = useState('')
|
||||||
|
const [useByDate, setUseByDate] = useState('')
|
||||||
|
const [locationId, setLocationId] = useState('')
|
||||||
|
const [amount, setAmount] = useState('1')
|
||||||
|
const [amountType, setAmountType] = useState('item')
|
||||||
|
const [cameraError, setCameraError] = useState('')
|
||||||
|
const [lookingUp, setLookingUp] = useState(false)
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!scanning) return
|
||||||
|
const videoElement = videoRef.current
|
||||||
|
let active = true
|
||||||
|
let stream: MediaStream | null = null
|
||||||
|
let intervalId: number | undefined
|
||||||
|
let stopFallbackScanner: (() => void) | undefined
|
||||||
|
let detecting = false
|
||||||
|
const cameraConstraints: MediaStreamConstraints = {
|
||||||
|
video: { facingMode: { ideal: 'environment' }, width: { ideal: 1280 } },
|
||||||
|
audio: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCamera() {
|
||||||
|
setCameraError('')
|
||||||
|
if (!('BarcodeDetector' in window)) {
|
||||||
|
try {
|
||||||
|
const { BrowserMultiFormatReader } = await import('@zxing/browser')
|
||||||
|
if (!active || !videoElement) return
|
||||||
|
|
||||||
|
const reader = new BrowserMultiFormatReader()
|
||||||
|
const controls = await reader.decodeFromConstraints(cameraConstraints, videoElement, (result, _error, scannerControls) => {
|
||||||
|
if (result && active) {
|
||||||
|
scannerControls.stop()
|
||||||
|
setBarcode(result.getText())
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
stopFallbackScanner = () => controls.stop()
|
||||||
|
if (!active) stopFallbackScanner()
|
||||||
|
} catch {
|
||||||
|
setCameraError('Camera access was unavailable. Check your browser permission or enter the barcode below.')
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia(cameraConstraints)
|
||||||
|
if (!active || !videoElement) return
|
||||||
|
videoElement.srcObject = stream
|
||||||
|
await videoElement.play()
|
||||||
|
const detector = new BarcodeDetector()
|
||||||
|
|
||||||
|
intervalId = window.setInterval(async () => {
|
||||||
|
if (!active || detecting || videoElement.readyState < 2) return
|
||||||
|
detecting = true
|
||||||
|
try {
|
||||||
|
const results = await detector.detect(videoElement)
|
||||||
|
const result = results.find((candidate) => candidate.rawValue)
|
||||||
|
if (result && active) {
|
||||||
|
setBarcode(result.rawValue)
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// A single missed frame is expected while the camera focuses.
|
||||||
|
} finally {
|
||||||
|
detecting = false
|
||||||
|
}
|
||||||
|
}, 450)
|
||||||
|
} catch {
|
||||||
|
setCameraError('Camera access was unavailable. Check your browser permission or enter the barcode below.')
|
||||||
|
setScanning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void startCamera()
|
||||||
|
return () => {
|
||||||
|
active = false
|
||||||
|
if (intervalId) window.clearInterval(intervalId)
|
||||||
|
stopFallbackScanner?.()
|
||||||
|
stream?.getTracks().forEach((track) => track.stop())
|
||||||
|
if (videoElement) videoElement.srcObject = null
|
||||||
|
}
|
||||||
|
}, [scanning])
|
||||||
|
|
||||||
|
const handleBarcodeLookup = async () => {
|
||||||
|
if (!barcode.trim()) {
|
||||||
|
setError('Scan or enter a barcode first.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLookingUp(true)
|
||||||
|
setError('')
|
||||||
|
setFallbackName('')
|
||||||
|
try {
|
||||||
|
const scannedBarcode = barcode.trim()
|
||||||
|
const matchingItems = await api.searchItems(scannedBarcode)
|
||||||
|
const existingItem = matchingItems.find(
|
||||||
|
(item) => item.barcode?.trim().toLowerCase() === scannedBarcode.toLowerCase(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (existingItem) {
|
||||||
|
setLookup({
|
||||||
|
barcode: existingItem.barcode ?? scannedBarcode,
|
||||||
|
title: existingItem.name,
|
||||||
|
size: existingItem.itemLookupSize,
|
||||||
|
images: existingItem.itemLookupImages,
|
||||||
|
})
|
||||||
|
setFallbackName(existingItem.name)
|
||||||
|
setStep('dates')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const product = await api.lookupBarcode(scannedBarcode)
|
||||||
|
setLookup(product)
|
||||||
|
setStep('dates')
|
||||||
|
} catch (caught) {
|
||||||
|
if (caught instanceof ApiError && caught.status === 404) {
|
||||||
|
setLookup(null)
|
||||||
|
setStep('dates')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(caught instanceof Error ? caught.message : 'We could not look up this barcode.')
|
||||||
|
} finally {
|
||||||
|
setLookingUp(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!lookup && !fallbackName.trim()) {
|
||||||
|
setError('Enter a product name so this item can be added.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: InventoryItemRequest = {
|
||||||
|
barcode: barcode.trim(),
|
||||||
|
...(fallbackName.trim() ? { name: fallbackName.trim() } : {}),
|
||||||
|
...(expiryDate ? { expiryDate: toApiDate(expiryDate) } : {}),
|
||||||
|
...(useByDate ? { useByDate: toApiDate(useByDate) } : {}),
|
||||||
|
...(locationId ? { locationId } : {}),
|
||||||
|
...(amount ? { amount: Number(amount) } : {}),
|
||||||
|
...(amountType ? { amountType } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
await api.createItem(request)
|
||||||
|
await onSaved(`${lookup?.title || fallbackName.trim()} was added to your pantry.`)
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'We could not add this barcode.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSubmit = (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (step === 'barcode') {
|
||||||
|
void handleBarcodeLookup()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (step === 'dates' && !lookup) {
|
||||||
|
setError('')
|
||||||
|
setStep('details')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void handleSave()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleCamera = () => {
|
||||||
|
if (scanning) {
|
||||||
|
setScanning(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setBarcode('')
|
||||||
|
setLookup(null)
|
||||||
|
setError('')
|
||||||
|
setCameraError('')
|
||||||
|
setScanning(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progress = (
|
||||||
|
<div className="scanner-steps" aria-label="Add item progress">
|
||||||
|
<span className={step === 'barcode' ? 'scanner-steps__item scanner-steps__item--active' : 'scanner-steps__item'}><i>1</i>Barcode</span>
|
||||||
|
<span className={step === 'dates' ? 'scanner-steps__item scanner-steps__item--active' : 'scanner-steps__item'}><i>2</i>Dates</span>
|
||||||
|
{(!lookup && step !== 'barcode') && <span className={step === 'details' ? 'scanner-steps__item scanner-steps__item--active' : 'scanner-steps__item'}><i>3</i>Details</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={step === 'barcode' ? 'Scan an item' : step === 'dates' ? 'Choose dates' : 'Add item details'} eyebrow="Quick add" size="large">
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
{step === 'barcode' && <div className="modal__body scanner-layout">
|
||||||
|
<div className="scanner-camera">
|
||||||
|
<div className={`camera-viewport ${barcode ? 'camera-viewport--captured' : ''}`}>
|
||||||
|
{scanning ? (
|
||||||
|
<video ref={videoRef} muted playsInline aria-label="Barcode camera preview" />
|
||||||
|
) : barcode ? (
|
||||||
|
<div className="scanner-captured">
|
||||||
|
<CheckCircle2 size={37} />
|
||||||
|
<strong>Barcode captured</strong>
|
||||||
|
<span>{barcode}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="scanner-placeholder">
|
||||||
|
<ScanBarcode size={48} strokeWidth={1.45} />
|
||||||
|
<strong>Ready when you are</strong>
|
||||||
|
<span>Place the barcode inside the frame</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{scanning && <div className="scan-frame"><i /></div>}
|
||||||
|
</div>
|
||||||
|
<button type="button" className={`button button--wide ${scanning ? 'button--ghost' : 'button--secondary'}`} onClick={toggleCamera}>
|
||||||
|
{scanning ? <><CameraOff size={18} />Stop camera</> : <><Camera size={18} />{barcode ? 'Scan again' : 'Start camera'}</>}
|
||||||
|
</button>
|
||||||
|
{cameraError && <p className="scanner-note scanner-note--error">{cameraError}</p>}
|
||||||
|
<p className="scanner-note"><Sparkles size={14} /> We’ll identify the product before asking for its dates.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="scanner-details">
|
||||||
|
<div className="scanner-details__heading">
|
||||||
|
<span>Barcode</span>
|
||||||
|
<small>Scan the pack or enter the number by hand</small>
|
||||||
|
</div>
|
||||||
|
<label className="field">
|
||||||
|
<span>Barcode number</span>
|
||||||
|
<input value={barcode} onChange={(event) => setBarcode(event.target.value)} inputMode="numeric" placeholder="e.g. 5012345678900" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{step === 'dates' && <div className="modal__body scanner-wizard">
|
||||||
|
{progress}
|
||||||
|
<div className="scanner-details">
|
||||||
|
<div className="scanner-details__heading"><span>Product and dates</span><small>Check the product, then add the dates from the pack</small></div>
|
||||||
|
<div className={`scanner-product ${lookup ? '' : 'scanner-product--missing'}`}>
|
||||||
|
{lookup ? <><span>Product found</span><strong>{lookup.title}</strong></> : <><span>Product not found</span><strong>We’ll ask for its details next.</strong></>}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="field field--featured"><span>Expiry date <small>optional</small></span><input type="date" value={expiryDate} onChange={(event) => setExpiryDate(event.target.value)} autoFocus /></label>
|
||||||
|
<label className="field field--featured"><span>Use-by date <small>optional</small></span><input type="date" value={useByDate} onChange={(event) => setUseByDate(event.target.value)} /></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{step === 'details' && <div className="modal__body scanner-wizard">
|
||||||
|
{progress}
|
||||||
|
<div className="scanner-details">
|
||||||
|
<div className="scanner-details__heading"><span>Item details</span><small>We could not identify this barcode, so add the essentials.</small></div>
|
||||||
|
<label className="field"><span>Product name</span><input value={fallbackName} onChange={(event) => setFallbackName(event.target.value)} placeholder="e.g. Greek yoghurt" autoFocus required /></label>
|
||||||
|
<div className="form-row form-row--quantity">
|
||||||
|
<label className="field"><span>Quantity</span><input type="number" min="0.1" step="0.1" value={amount} onChange={(event) => setAmount(event.target.value)} /></label>
|
||||||
|
<label className="field"><span>Unit</span><select value={amountType} onChange={(event) => setAmountType(event.target.value)}><option value="item">item</option><option value="pack">pack</option><option value="bottle">bottle</option><option value="tin">tin</option><option value="g">grams</option><option value="kg">kilograms</option><option value="ml">millilitres</option><option value="litres">litres</option></select></label>
|
||||||
|
</div>
|
||||||
|
<label className="field"><span>Storage location <small>optional</small></span><select value={locationId} onChange={(event) => setLocationId(event.target.value)}><option value="">Choose later</option>{locations.map((location) => <option value={location.id} key={location.id}>{location.name}</option>)}</select></label>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
|
||||||
|
{error && <div className="scanner-wizard__error form-error" role="alert">{error}</div>}
|
||||||
|
<div className="modal__footer">
|
||||||
|
{step === 'barcode' ? <button type="button" className="button button--ghost" onClick={onClose} disabled={lookingUp}>Cancel</button> : <button type="button" className="button button--ghost" onClick={() => { setError(''); setStep(step === 'details' ? 'dates' : 'barcode') }} disabled={saving}><ArrowLeft size={17} />Back</button>}
|
||||||
|
<button type="submit" className="button button--primary" disabled={lookingUp || saving || (step === 'barcode' && !barcode.trim())}>
|
||||||
|
{(lookingUp || saving) && <Spinner />}{lookingUp ? 'Finding product…' : saving ? 'Adding item…' : step === 'barcode' ? 'Continue' : step === 'dates' ? lookup ? 'Add item' : 'Continue to details' : 'Add item'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
130
src/components/InventoryItemModal.tsx
Normal file
130
src/components/InventoryItemModal.tsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
import { CalendarDays, PackagePlus } from 'lucide-react'
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { toApiDate, toDateInput } from '../lib/format'
|
||||||
|
import type { InventoryItem, InventoryItemRequest, Location } from '../types'
|
||||||
|
import { Modal, Spinner } from './ui'
|
||||||
|
|
||||||
|
interface InventoryItemModalProps {
|
||||||
|
item?: InventoryItem | null
|
||||||
|
locations: Location[]
|
||||||
|
onClose: () => void
|
||||||
|
onSaved: (message: string) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryItemModal({ item, locations, onClose, onSaved }: InventoryItemModalProps) {
|
||||||
|
const [name, setName] = useState(item?.name ?? '')
|
||||||
|
const [barcode, setBarcode] = useState(item?.barcode ?? '')
|
||||||
|
const [expiryDate, setExpiryDate] = useState(toDateInput(item?.expiryDate))
|
||||||
|
const [useByDate, setUseByDate] = useState(toDateInput(item?.useByDate))
|
||||||
|
const [amount, setAmount] = useState(item?.amount?.toString() ?? '1')
|
||||||
|
const [amountType, setAmountType] = useState(item?.amountType ?? 'item')
|
||||||
|
const [locationId, setLocationId] = useState(item?.locationId ?? '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!name.trim() && !barcode.trim()) {
|
||||||
|
setError('Add a product name or barcode so the item can be identified.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: InventoryItemRequest = {
|
||||||
|
...(name.trim() ? { name: name.trim() } : {}),
|
||||||
|
...(barcode.trim() ? { barcode: barcode.trim() } : {}),
|
||||||
|
...(expiryDate ? { expiryDate: toApiDate(expiryDate) } : {}),
|
||||||
|
...(useByDate ? { useByDate: toApiDate(useByDate) } : {}),
|
||||||
|
...(amount ? { amount: Number(amount) } : {}),
|
||||||
|
...(amountType.trim() ? { amountType: amountType.trim() } : {}),
|
||||||
|
...(locationId ? { locationId } : {}),
|
||||||
|
}
|
||||||
|
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
if (item) {
|
||||||
|
await api.updateItem(item.id, request)
|
||||||
|
await onSaved(`${name.trim() || item.name} was updated.`)
|
||||||
|
} else {
|
||||||
|
await api.createItem(request)
|
||||||
|
await onSaved(`${name.trim() || 'Your scanned item'} was added to the pantry.`)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'We could not save this item.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={item ? 'Edit pantry item' : 'Add pantry item'} eyebrow={item ? 'Update details' : 'New item'}>
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<div className="modal__body">
|
||||||
|
<div className="form-intro">
|
||||||
|
<span><PackagePlus size={21} /></span>
|
||||||
|
<p><strong>{item ? 'Keep the details accurate' : 'Add it your way'}</strong>{item ? 'Changes appear in your pantry straight away.' : 'Enter a name, or use a barcode and we’ll identify it through the API.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Product name</span>
|
||||||
|
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. Greek yoghurt" autoFocus />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="field">
|
||||||
|
<span>Expiry date</span>
|
||||||
|
<div className="input-with-icon"><CalendarDays size={17} /><input type="date" value={expiryDate} onChange={(event) => setExpiryDate(event.target.value)} /></div>
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Use-by date <small>optional</small></span>
|
||||||
|
<input type="date" value={useByDate} onChange={(event) => setUseByDate(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-row form-row--quantity">
|
||||||
|
<label className="field">
|
||||||
|
<span>Quantity</span>
|
||||||
|
<input type="number" min="0.1" step="0.1" value={amount} onChange={(event) => setAmount(event.target.value)} />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Unit</span>
|
||||||
|
<select value={amountType} onChange={(event) => setAmountType(event.target.value)}>
|
||||||
|
<option value="item">item</option>
|
||||||
|
<option value="pack">pack</option>
|
||||||
|
<option value="bottle">bottle</option>
|
||||||
|
<option value="tin">tin</option>
|
||||||
|
<option value="g">grams</option>
|
||||||
|
<option value="kg">kilograms</option>
|
||||||
|
<option value="ml">millilitres</option>
|
||||||
|
<option value="litres">litres</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Storage location</span>
|
||||||
|
<select value={locationId} onChange={(event) => setLocationId(event.target.value)}>
|
||||||
|
<option value="">No location</option>
|
||||||
|
{locations.map((location) => <option value={location.id} key={location.id}>{location.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Barcode <small>optional</small></span>
|
||||||
|
<input value={barcode} onChange={(event) => setBarcode(event.target.value)} inputMode="numeric" placeholder="Type the number below the barcode" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button type="button" className="button button--ghost" onClick={onClose} disabled={saving}>Cancel</button>
|
||||||
|
<button type="submit" className="button button--primary" disabled={saving}>
|
||||||
|
{saving && <Spinner />}{item ? 'Save changes' : 'Add to pantry'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
src/components/Logo.tsx
Normal file
12
src/components/Logo.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
export function Logo({ compact = false }: { compact?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className={`brand ${compact ? 'brand--compact' : ''}`} aria-label="Keeply">
|
||||||
|
<span className="brand__mark" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
{!compact && <span className="brand__name">keeply</span>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
125
src/components/ui.tsx
Normal file
125
src/components/ui.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { AlertTriangle, LoaderCircle, X } from 'lucide-react'
|
||||||
|
import { useEffect } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
|
||||||
|
export function FullPageLoader() {
|
||||||
|
return (
|
||||||
|
<div className="full-page-loader" role="status">
|
||||||
|
<span className="loader-mark" aria-hidden="true"><i /><i /><i /></span>
|
||||||
|
<span>Loading your pantry…</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Spinner({ size = 18 }: { size?: number }) {
|
||||||
|
return <LoaderCircle className="spinner" size={size} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean
|
||||||
|
onClose: () => void
|
||||||
|
title: string
|
||||||
|
eyebrow?: string
|
||||||
|
children: React.ReactNode
|
||||||
|
size?: 'small' | 'medium' | 'large'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, title, eyebrow, children, size = 'medium' }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
const handleKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') onClose()
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKey)
|
||||||
|
document.body.classList.add('modal-open')
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('keydown', handleKey)
|
||||||
|
document.body.classList.remove('modal-open')
|
||||||
|
}
|
||||||
|
}, [open, onClose])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="modal-layer" role="presentation">
|
||||||
|
<button className="modal-backdrop" onClick={onClose} aria-label="Close dialog" />
|
||||||
|
<section className={`modal modal--${size}`} role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div className="modal__header">
|
||||||
|
<div>
|
||||||
|
{eyebrow && <p className="eyebrow">{eyebrow}</p>}
|
||||||
|
<h2 id="modal-title">{title}</h2>
|
||||||
|
</div>
|
||||||
|
<button className="icon-button" onClick={onClose} aria-label="Close dialog">
|
||||||
|
<X size={19} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ConfirmDialogProps {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
confirmLabel?: string
|
||||||
|
loading?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onClose: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Delete',
|
||||||
|
loading,
|
||||||
|
onConfirm,
|
||||||
|
onClose,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={title} size="small">
|
||||||
|
<div className="confirm-dialog">
|
||||||
|
<div className="confirm-dialog__icon"><AlertTriangle size={23} /></div>
|
||||||
|
<p>{message}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button className="button button--ghost" onClick={onClose} disabled={loading}>Cancel</button>
|
||||||
|
<button className="button button--danger" onClick={onConfirm} disabled={loading}>
|
||||||
|
{loading && <Spinner />}{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon: React.ComponentType<{ size?: number }>
|
||||||
|
title: string
|
||||||
|
message: string
|
||||||
|
action?: React.ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="empty-state">
|
||||||
|
<div className="empty-state__icon"><Icon size={28} /></div>
|
||||||
|
<h3>{title}</h3>
|
||||||
|
<p>{message}</p>
|
||||||
|
{action}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageSkeleton({ cards = 6 }: { cards?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="skeleton-grid" aria-label="Loading">
|
||||||
|
{Array.from({ length: cards }, (_, index) => <div className="skeleton-card" key={index} />)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
91
src/context/AuthContext.tsx
Normal file
91
src/context/AuthContext.tsx
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { api, tokenStore } from '../lib/api'
|
||||||
|
import type { LoginRequest, RegisterRequest, User } from '../types'
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: User | null
|
||||||
|
loading: boolean
|
||||||
|
login: (request: LoginRequest) => Promise<void>
|
||||||
|
register: (request: RegisterRequest) => Promise<void>
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refreshProfile: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const refreshProfile = useCallback(async () => {
|
||||||
|
const profile = await api.getProfile()
|
||||||
|
setUser(profile)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
async function restoreSession() {
|
||||||
|
if (!tokenStore.getAccess()) {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profile = await api.getProfile()
|
||||||
|
if (!cancelled) setUser(profile)
|
||||||
|
} catch {
|
||||||
|
tokenStore.clear()
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void restoreSession()
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const login = useCallback(async (request: LoginRequest) => {
|
||||||
|
const response = await api.login(request)
|
||||||
|
if (!response.success || !response.accessToken || !response.refreshToken || !response.user) {
|
||||||
|
throw new Error(response.message || 'Unable to sign in.')
|
||||||
|
}
|
||||||
|
tokenStore.set(response.accessToken, response.refreshToken)
|
||||||
|
setUser(response.user)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const register = useCallback(async (request: RegisterRequest) => {
|
||||||
|
const response = await api.register(request)
|
||||||
|
if (!response.success || !response.accessToken || !response.refreshToken || !response.user) {
|
||||||
|
throw new Error(response.message || 'Unable to create your account.')
|
||||||
|
}
|
||||||
|
tokenStore.set(response.accessToken, response.refreshToken)
|
||||||
|
setUser(response.user)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await api.logout()
|
||||||
|
} finally {
|
||||||
|
tokenStore.clear()
|
||||||
|
setUser(null)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ user, loading, login, register, logout, refreshProfile }),
|
||||||
|
[user, loading, login, register, logout, refreshProfile],
|
||||||
|
)
|
||||||
|
|
||||||
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context hooks intentionally share the provider module.
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useAuth() {
|
||||||
|
const context = useContext(AuthContext)
|
||||||
|
if (!context) throw new Error('useAuth must be used within an AuthProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
60
src/context/ToastContext.tsx
Normal file
60
src/context/ToastContext.tsx
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { CheckCircle2, CircleAlert, Info, X } from 'lucide-react'
|
||||||
|
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
|
||||||
|
import type { ToastKind, ToastMessage } from '../types'
|
||||||
|
|
||||||
|
interface ToastContextValue {
|
||||||
|
showToast: (kind: ToastKind, title: string, message?: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const ToastContext = createContext<ToastContextValue | undefined>(undefined)
|
||||||
|
|
||||||
|
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||||
|
const nextId = useRef(1)
|
||||||
|
|
||||||
|
const dismiss = useCallback((id: number) => {
|
||||||
|
setToasts((current) => current.filter((toast) => toast.id !== id))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const showToast = useCallback(
|
||||||
|
(kind: ToastKind, title: string, message?: string) => {
|
||||||
|
const id = nextId.current++
|
||||||
|
setToasts((current) => [...current, { id, kind, title, message }])
|
||||||
|
window.setTimeout(() => dismiss(id), 4500)
|
||||||
|
},
|
||||||
|
[dismiss],
|
||||||
|
)
|
||||||
|
|
||||||
|
const value = useMemo(() => ({ showToast }), [showToast])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ToastContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="toast-stack" aria-live="polite">
|
||||||
|
{toasts.map((toast) => {
|
||||||
|
const Icon = toast.kind === 'success' ? CheckCircle2 : toast.kind === 'error' ? CircleAlert : Info
|
||||||
|
return (
|
||||||
|
<div className={`toast toast--${toast.kind}`} key={toast.id}>
|
||||||
|
<Icon size={19} aria-hidden="true" />
|
||||||
|
<div className="toast__copy">
|
||||||
|
<strong>{toast.title}</strong>
|
||||||
|
{toast.message && <span>{toast.message}</span>}
|
||||||
|
</div>
|
||||||
|
<button className="icon-button icon-button--small" onClick={() => dismiss(toast.id)} aria-label="Dismiss notification">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</ToastContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context hooks intentionally share the provider module.
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components
|
||||||
|
export function useToast() {
|
||||||
|
const context = useContext(ToastContext)
|
||||||
|
if (!context) throw new Error('useToast must be used within a ToastProvider')
|
||||||
|
return context
|
||||||
|
}
|
||||||
548
src/index.css
Normal file
548
src/index.css
Normal file
@@ -0,0 +1,548 @@
|
|||||||
|
:root {
|
||||||
|
font-family: 'DM Sans', Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
color: #24332a;
|
||||||
|
background: #f7f7f2;
|
||||||
|
font-synthesis: none;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
--ink: #24332a;
|
||||||
|
--muted: #738078;
|
||||||
|
--faint: #9aa39d;
|
||||||
|
--line: #e4e7e1;
|
||||||
|
--surface: #ffffff;
|
||||||
|
--canvas: #f7f7f2;
|
||||||
|
--green: #2f6d4f;
|
||||||
|
--green-dark: #24563e;
|
||||||
|
--green-soft: #e9f2ec;
|
||||||
|
--sage: #dfe9df;
|
||||||
|
--cream: #f4f0e4;
|
||||||
|
--danger: #ae4b45;
|
||||||
|
--danger-soft: #faeae7;
|
||||||
|
--warning: #ad7026;
|
||||||
|
--warning-soft: #fbf0dc;
|
||||||
|
--blue: #4b7082;
|
||||||
|
--blue-soft: #e4eff3;
|
||||||
|
--shadow-sm: 0 2px 8px rgba(28, 48, 36, 0.05);
|
||||||
|
--shadow-md: 0 14px 38px rgba(31, 51, 39, 0.1);
|
||||||
|
--shadow-lg: 0 28px 75px rgba(22, 43, 31, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html { min-width: 320px; background: var(--canvas); }
|
||||||
|
body { margin: 0; min-width: 320px; min-height: 100vh; background: var(--canvas); }
|
||||||
|
body.modal-open { overflow: hidden; }
|
||||||
|
button, input, select, textarea { font: inherit; }
|
||||||
|
button { color: inherit; }
|
||||||
|
button, a { -webkit-tap-highlight-color: transparent; }
|
||||||
|
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 3px solid rgba(47, 109, 79, 0.2); outline-offset: 2px; }
|
||||||
|
h1, h2, h3, p { margin-top: 0; }
|
||||||
|
h1, h2, h3, .brand__name { font-family: Manrope, 'DM Sans', sans-serif; }
|
||||||
|
a { color: inherit; text-decoration: none; }
|
||||||
|
img { max-width: 100%; }
|
||||||
|
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||||
|
|
||||||
|
/* Brand and primitives */
|
||||||
|
.brand { display: inline-flex; align-items: center; gap: 10px; flex-shrink: 0; }
|
||||||
|
.brand__name { color: var(--ink); font-size: 23px; font-weight: 800; letter-spacing: -1px; }
|
||||||
|
.brand__mark, .loader-mark { position: relative; display: inline-block; width: 31px; height: 28px; }
|
||||||
|
.brand__mark i, .loader-mark i { position: absolute; display: block; width: 10px; height: 23px; border-radius: 10px 10px 4px 10px; background: var(--green); transform-origin: bottom center; }
|
||||||
|
.brand__mark i:nth-child(1), .loader-mark i:nth-child(1) { left: 2px; bottom: 1px; transform: rotate(-39deg); }
|
||||||
|
.brand__mark i:nth-child(2), .loader-mark i:nth-child(2) { left: 10px; bottom: 5px; height: 22px; transform: rotate(-3deg); background: #5b916d; }
|
||||||
|
.brand__mark i:nth-child(3), .loader-mark i:nth-child(3) { right: 2px; bottom: 2px; transform: rotate(39deg); background: #8bb594; }
|
||||||
|
.brand--compact .brand__mark { margin-right: 0; }
|
||||||
|
|
||||||
|
.eyebrow { margin: 0 0 7px; color: var(--green); font-size: 11px; line-height: 1.3; font-weight: 700; letter-spacing: 1.65px; text-transform: uppercase; }
|
||||||
|
.eyebrow--light { color: #9bc3a4; }
|
||||||
|
.button { min-height: 42px; padding: 0 17px; border: 1px solid transparent; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; gap: 8px; color: var(--ink); background: transparent; font-weight: 700; font-size: 13.5px; cursor: pointer; transition: transform 160ms ease, background 160ms ease, border-color 160ms ease, box-shadow 160ms ease; }
|
||||||
|
.button:hover:not(:disabled) { transform: translateY(-1px); }
|
||||||
|
.button:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||||
|
.button--primary { color: #fff; background: var(--green); box-shadow: 0 5px 13px rgba(47, 109, 79, 0.18); }
|
||||||
|
.button--primary:hover:not(:disabled) { background: var(--green-dark); box-shadow: 0 7px 17px rgba(47, 109, 79, 0.24); }
|
||||||
|
.button--secondary { border-color: #ccd8cf; color: var(--green-dark); background: #fff; }
|
||||||
|
.button--secondary:hover:not(:disabled) { border-color: #8aaa94; background: #f8fbf9; }
|
||||||
|
.button--ghost { color: #647168; background: transparent; }
|
||||||
|
.button--ghost:hover:not(:disabled) { background: #f1f3ef; }
|
||||||
|
.button--soft { color: var(--green-dark); background: var(--green-soft); }
|
||||||
|
.button--danger { color: #fff; background: var(--danger); }
|
||||||
|
.button--small { min-height: 34px; padding: 0 11px; border-radius: 8px; font-size: 12px; }
|
||||||
|
.button--large { min-height: 50px; border-radius: 12px; font-size: 14px; }
|
||||||
|
.button--wide { width: 100%; }
|
||||||
|
.button--text-danger { color: var(--danger); }
|
||||||
|
.icon-button { width: 38px; height: 38px; padding: 0; border: 0; border-radius: 10px; display: inline-flex; align-items: center; justify-content: center; color: #66746b; background: transparent; cursor: pointer; transition: background 160ms ease, color 160ms ease, transform 160ms ease; }
|
||||||
|
.icon-button:hover { color: var(--green-dark); background: #eef2ed; }
|
||||||
|
.icon-button--small { width: 28px; height: 28px; border-radius: 7px; }
|
||||||
|
.icon-button--surface { width: 33px; height: 33px; color: #4d5b52; background: rgba(255,255,255,0.93); box-shadow: 0 4px 13px rgba(31, 48, 38, 0.12); }
|
||||||
|
.icon-button--danger:hover { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
.spinner { animation: spin 850ms linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
/* Authentication */
|
||||||
|
.auth-page { min-height: 100vh; display: grid; grid-template-columns: minmax(420px, 0.95fr) minmax(500px, 1.05fr); background: #f7f7f2; }
|
||||||
|
.auth-story { position: relative; min-height: 100vh; padding: 38px clamp(42px, 5vw, 80px); display: flex; flex-direction: column; color: #f7fbf8; background: #244c39; overflow: hidden; isolation: isolate; }
|
||||||
|
.auth-story::before { content: ''; position: absolute; inset: 0; z-index: -2; opacity: 0.11; background-image: radial-gradient(#d1e3d5 0.8px, transparent 0.8px); background-size: 22px 22px; mask-image: linear-gradient(to bottom, black, transparent 86%); }
|
||||||
|
.auth-story__top { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.auth-story .brand__name { color: #fff; }
|
||||||
|
.auth-story .brand__mark i { background: #dceade; }
|
||||||
|
.auth-story .brand__mark i:nth-child(2) { background: #a7cbaa; }
|
||||||
|
.auth-story .brand__mark i:nth-child(3) { background: #77a883; }
|
||||||
|
.auth-story__pill { padding: 8px 12px; border: 1px solid rgba(224,239,226,0.18); border-radius: 999px; display: flex; align-items: center; gap: 6px; color: #cfe2d3; background: rgba(255,255,255,0.05); font-size: 11px; font-weight: 600; }
|
||||||
|
.auth-story__content { margin: auto 0; max-width: 600px; padding: 72px 0 50px; }
|
||||||
|
.auth-story h1 { margin: 0 0 22px; color: #f8faf7; font-size: clamp(43px, 4.4vw, 70px); line-height: 1.03; letter-spacing: -3.4px; }
|
||||||
|
.auth-story h1 em { color: #aaceaf; font-family: Georgia, serif; font-weight: 400; letter-spacing: -2px; }
|
||||||
|
.auth-story__lead { max-width: 520px; margin-bottom: 37px; color: #bed0c3; font-size: clamp(16px, 1.4vw, 19px); line-height: 1.65; }
|
||||||
|
.auth-benefits { display: grid; gap: 16px; }
|
||||||
|
.auth-benefits > div { display: flex; align-items: flex-start; gap: 13px; }
|
||||||
|
.auth-benefits > div > span { width: 30px; height: 30px; border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: #d8e9db; background: rgba(188, 222, 196, 0.13); }
|
||||||
|
.auth-benefits p { margin: 0; display: flex; flex-direction: column; color: #9fb6a5; font-size: 12.5px; line-height: 1.55; }
|
||||||
|
.auth-benefits strong { margin-bottom: 1px; color: #edf4ee; font-size: 13.5px; }
|
||||||
|
.auth-story__quote { max-width: 460px; margin: 0; padding-left: 15px; border-left: 2px solid #6f9f7a; color: #9fb6a5; font-family: Georgia, serif; font-size: 13px; font-style: italic; line-height: 1.6; }
|
||||||
|
.auth-story__orb { position: absolute; z-index: -1; border-radius: 50%; filter: blur(2px); }
|
||||||
|
.auth-story__orb--one { width: 310px; height: 310px; right: -150px; bottom: -100px; border: 65px solid rgba(150, 190, 159, 0.08); }
|
||||||
|
.auth-story__orb--two { width: 220px; height: 220px; right: 9%; top: 24%; border: 1px solid rgba(185, 219, 192, 0.12); }
|
||||||
|
.auth-panel { padding: 40px clamp(35px, 7vw, 110px); display: flex; flex-direction: column; align-items: center; justify-content: center; }
|
||||||
|
.auth-card { width: min(100%, 445px); }
|
||||||
|
.auth-card__heading { margin-bottom: 31px; }
|
||||||
|
.auth-card__heading h2 { margin: 0 0 8px; color: #203027; font-size: clamp(28px, 3vw, 38px); letter-spacing: -1.6px; }
|
||||||
|
.auth-card__heading > p:last-child { margin: 0; color: var(--muted); font-size: 14px; }
|
||||||
|
.auth-form { display: grid; gap: 17px; }
|
||||||
|
.auth-card__switch { margin: 25px 0 0; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
|
.auth-card__switch button { padding: 0; border: 0; color: var(--green); background: none; font-weight: 700; cursor: pointer; }
|
||||||
|
.auth-panel__footer { position: absolute; bottom: 20px; margin: 0; color: #9da59f; font-size: 10.5px; letter-spacing: 0.3px; }
|
||||||
|
|
||||||
|
/* Forms */
|
||||||
|
.field { min-width: 0; display: flex; flex-direction: column; gap: 7px; color: #38473e; font-size: 12.5px; font-weight: 700; }
|
||||||
|
.field > span { display: flex; align-items: baseline; justify-content: space-between; }
|
||||||
|
.field span small { color: #9aa39d; font-size: 10.5px; font-weight: 500; }
|
||||||
|
.field > small { margin-top: -1px; color: #8c9690; font-size: 10.5px; font-weight: 400; }
|
||||||
|
.field input, .field select, .field textarea, .search-field input { width: 100%; border: 1px solid #dce1dc; border-radius: 10px; color: #2a392f; background: #fff; transition: border-color 160ms ease, box-shadow 160ms ease; }
|
||||||
|
.field input, .field select { height: 44px; padding: 0 13px; }
|
||||||
|
.field textarea { padding: 12px 13px; line-height: 1.5; resize: vertical; }
|
||||||
|
.field input::placeholder, .field textarea::placeholder, .search-field input::placeholder { color: #a5ada8; }
|
||||||
|
.field input:focus, .field select:focus, .field textarea:focus, .search-field input:focus { outline: 0; border-color: #7da28a; box-shadow: 0 0 0 3px rgba(63, 121, 84, 0.1); }
|
||||||
|
.field--featured { padding: 12px; border: 1px solid #d5e3d8; border-radius: 12px; background: #f6faf7; }
|
||||||
|
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 13px; }
|
||||||
|
.form-row--quantity { grid-template-columns: 0.75fr 1.25fr; }
|
||||||
|
.input-with-action, .input-with-icon { position: relative; display: flex; align-items: center; }
|
||||||
|
.input-with-action input { padding-right: 45px; }
|
||||||
|
.input-with-action button { position: absolute; right: 5px; width: 36px; height: 36px; padding: 0; border: 0; border-radius: 8px; display: flex; align-items: center; justify-content: center; color: #829087; background: none; cursor: pointer; }
|
||||||
|
.input-with-icon > svg { position: absolute; left: 13px; z-index: 1; color: #809087; }
|
||||||
|
.input-with-icon input { padding-left: 40px; }
|
||||||
|
.form-error, .form-warning { padding: 10px 12px; border-radius: 9px; font-size: 12px; font-weight: 600; line-height: 1.5; }
|
||||||
|
.form-error { border: 1px solid #efcfca; color: #8f3f39; background: #fcece9; }
|
||||||
|
.form-warning { border: 1px solid #ecd8af; color: #895d26; background: #fff7e8; }
|
||||||
|
.form-intro { padding: 13px; border-radius: 12px; display: flex; align-items: center; gap: 12px; background: var(--green-soft); }
|
||||||
|
.form-intro > span { width: 38px; height: 38px; border-radius: 10px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--green); background: rgba(255,255,255,0.7); }
|
||||||
|
.form-intro p { margin: 0; display: flex; flex-direction: column; color: #728078; font-size: 11px; line-height: 1.45; }
|
||||||
|
.form-intro strong { margin-bottom: 1px; color: #31473a; font-size: 12.5px; }
|
||||||
|
|
||||||
|
/* App shell */
|
||||||
|
.app-shell { min-height: 100vh; }
|
||||||
|
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 40; width: 242px; padding: 27px 17px 17px; display: flex; flex-direction: column; border-right: 1px solid #e2e5df; background: #fbfbf7; }
|
||||||
|
.sidebar__top { min-height: 45px; padding: 0 10px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.sidebar__close { display: none; }
|
||||||
|
.sidebar__nav { margin-top: 32px; display: flex; flex-direction: column; }
|
||||||
|
.sidebar__eyebrow { margin: 0 11px 9px; color: #9aa39d; font-size: 9px; font-weight: 700; letter-spacing: 1.4px; text-transform: uppercase; }
|
||||||
|
.sidebar__eyebrow--spaced { margin-top: 26px; }
|
||||||
|
.nav-item { height: 43px; margin-bottom: 3px; padding: 0 12px; border-radius: 9px; display: flex; align-items: center; gap: 12px; color: #69766e; font-size: 13px; font-weight: 600; transition: color 160ms ease, background 160ms ease; }
|
||||||
|
.nav-item:hover { color: var(--green-dark); background: #f0f3ee; }
|
||||||
|
.nav-item--active { color: var(--green-dark); background: #e8efe8; font-weight: 700; }
|
||||||
|
.nav-item--active svg { color: var(--green); }
|
||||||
|
.sidebar__footer { margin-top: auto; padding-top: 14px; border-top: 1px solid #e3e6e1; }
|
||||||
|
.profile-chip { padding: 7px; border-radius: 11px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; }
|
||||||
|
.profile-chip:hover { background: #f1f3ef; }
|
||||||
|
.profile-chip__copy { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.profile-chip__copy strong { overflow: hidden; color: #314037; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.profile-chip__copy small { margin-top: 2px; color: #939d97; font-size: 9.5px; }
|
||||||
|
.profile-chip > svg { color: #adb4af; }
|
||||||
|
.avatar { width: 35px; height: 35px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; color: #31553f; background: #dce9df; font-size: 10px; font-weight: 800; letter-spacing: 0.4px; }
|
||||||
|
.avatar--small { width: 32px; height: 32px; }
|
||||||
|
.avatar--member { width: 34px; height: 34px; }
|
||||||
|
.avatar--large { width: 50px; height: 50px; font-size: 14px; }
|
||||||
|
.avatar--profile { width: 84px; height: 84px; font-size: 24px; box-shadow: inset 0 0 0 1px rgba(47,109,79,.08); }
|
||||||
|
.sidebar__actions { margin-top: 7px; display: grid; grid-template-columns: 1fr 1fr; gap: 5px; }
|
||||||
|
.sidebar-action { height: 33px; padding: 0 7px; border: 0; border-radius: 8px; display: flex; align-items: center; justify-content: center; gap: 6px; color: #849087; background: transparent; font-size: 9.5px; cursor: pointer; }
|
||||||
|
.sidebar-action:hover { color: var(--green-dark); background: #f0f3ef; }
|
||||||
|
.app-main { min-height: 100vh; margin-left: 242px; animation: pageIn 240ms ease both; }
|
||||||
|
@keyframes pageIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
|
||||||
|
.mobile-header { display: none; }
|
||||||
|
.sidebar-backdrop { display: none; }
|
||||||
|
|
||||||
|
/* Page structure */
|
||||||
|
.page { width: min(100%, 1420px); margin: 0 auto; padding: 42px clamp(28px, 4vw, 64px) 70px; }
|
||||||
|
.page-header { min-height: 78px; margin-bottom: 30px; display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; }
|
||||||
|
.page-header h1 { margin: 0 0 5px; color: #213128; font-size: clamp(31px, 3vw, 43px); line-height: 1.12; letter-spacing: -1.9px; }
|
||||||
|
.page-header > div:first-child > p:last-child { margin: 0; color: var(--muted); font-size: 13.5px; }
|
||||||
|
.page-header__actions { display: flex; gap: 9px; flex-shrink: 0; }
|
||||||
|
.info-banner { margin: -12px 0 24px; padding: 13px 16px; border: 1px solid #dce7de; border-radius: 12px; display: flex; align-items: center; gap: 12px; color: var(--green); background: #f2f7f3; }
|
||||||
|
.info-banner div { display: flex; flex-direction: column; }
|
||||||
|
.info-banner strong { color: #375141; font-size: 12.5px; }
|
||||||
|
.info-banner span { margin-top: 2px; color: #7e8d83; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Summary and inventory toolbar */
|
||||||
|
.summary-strip { min-height: 80px; margin-bottom: 18px; padding: 14px 22px; border: 1px solid var(--line); border-radius: 14px; display: grid; grid-template-columns: repeat(7, auto); align-items: center; justify-content: space-around; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.summary-item { min-width: 130px; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.summary-item > span { width: 38px; height: 38px; border-radius: 11px; display: flex; align-items: center; justify-content: center; background: var(--green-soft); color: var(--green); }
|
||||||
|
.summary-item p { margin: 0; display: flex; flex-direction: column; }
|
||||||
|
.summary-item strong { color: #29382f; font-family: Manrope, sans-serif; font-size: 20px; line-height: 1; }
|
||||||
|
.summary-item small { margin-top: 4px; color: #8b958f; font-size: 10.5px; }
|
||||||
|
.summary-item--warning > span { color: var(--warning); background: var(--warning-soft); }
|
||||||
|
.summary-item--danger > span { color: var(--danger); background: var(--danger-soft); }
|
||||||
|
.summary-item--fresh > span { color: #4d7c58; background: #e7f1e8; }
|
||||||
|
.summary-divider { width: 1px; height: 34px; background: var(--line); }
|
||||||
|
.inventory-toolbar { padding: 11px; border: 1px solid var(--line); border-radius: 13px; display: grid; grid-template-columns: minmax(220px, 1fr) auto auto; gap: 8px; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.search-field { height: 40px; padding: 0 11px; border: 1px solid #e0e4df; border-radius: 9px; display: flex; align-items: center; gap: 9px; color: #839087; background: #fafbf8; }
|
||||||
|
.search-field input { height: 36px; padding: 0; border: 0; background: transparent; font-size: 12px; }
|
||||||
|
.search-field input:focus { box-shadow: none; }
|
||||||
|
.toolbar-select { position: relative; min-width: 142px; height: 40px; padding-left: 11px; border: 1px solid #e0e4df; border-radius: 9px; display: flex; align-items: center; gap: 5px; color: #728077; background: #fafbf8; }
|
||||||
|
.toolbar-select select { position: relative; z-index: 1; width: 100%; height: 100%; padding: 0 26px 0 3px; border: 0; appearance: none; color: #526057; background: transparent; font-size: 11px; font-weight: 600; cursor: pointer; outline: 0; }
|
||||||
|
.toolbar-select > svg:last-child { position: absolute; right: 8px; }
|
||||||
|
.inventory-filter-button { display: none; }
|
||||||
|
.inventory-toolbar__filters { display: contents; }
|
||||||
|
.inventory-scan-button--floating { display: none; }
|
||||||
|
.inventory-results-heading { min-height: 44px; padding: 0 3px; display: flex; align-items: center; justify-content: space-between; color: #8a958e; font-size: 11px; }
|
||||||
|
.inventory-results-heading p { margin: 0; }
|
||||||
|
.inventory-results-heading strong { color: #516158; }
|
||||||
|
.inventory-results-heading button { padding: 0; border: 0; color: var(--green); background: transparent; font-size: 11px; font-weight: 700; cursor: pointer; }
|
||||||
|
|
||||||
|
/* Inventory table */
|
||||||
|
.inventory-table-wrap { border: 1px solid #e0e5df; border-radius: 14px; overflow-x: auto; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.inventory-table { width: 100%; min-width: 790px; border-collapse: collapse; }
|
||||||
|
.inventory-table th { padding: 11px 14px; color: #7f8b83; background: #fafbf9; font-size: 9px; font-weight: 700; letter-spacing: 1px; text-align: left; text-transform: uppercase; white-space: nowrap; }
|
||||||
|
.inventory-table td { padding: 11px 14px; border-top: 1px solid #e9ece8; color: #617067; font-size: 11px; vertical-align: middle; }
|
||||||
|
.inventory-table tr:hover td { background: #fcfdfb; }
|
||||||
|
.inventory-table th:last-child, .inventory-table td:last-child { width: 1%; text-align: right; }
|
||||||
|
.inventory-table__sort { padding: 0; border: 0; display: inline-flex; align-items: center; gap: 5px; color: inherit; background: transparent; font: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }
|
||||||
|
.inventory-table__sort:hover { color: var(--green); }
|
||||||
|
.inventory-table__item { min-width: 170px; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.inventory-table__item > span:last-child { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.inventory-table__item strong { overflow: hidden; color: #34433a; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.inventory-table__item small { margin-top: 2px; overflow: hidden; color: #929d96; font-size: 9.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.inventory-table__image { position: relative; width: 34px; height: 34px; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; overflow: hidden; color: #5c8066; background: var(--green-soft); }
|
||||||
|
.inventory-table__image img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; mix-blend-mode: multiply; background: #fff; }
|
||||||
|
.inventory-table__detail { color: #929d96; font-size: 10px; }
|
||||||
|
.inventory-table .status-chip { position: static; display: inline-flex; box-shadow: none; white-space: nowrap; }
|
||||||
|
.inventory-table__actions { display: flex; justify-content: flex-end; gap: 3px; }
|
||||||
|
|
||||||
|
.inventory-table__group-row td { background: #f7fbf8; }
|
||||||
|
.inventory-table .inventory-table__group-row:hover td { background: #f1f7f2; }
|
||||||
|
.inventory-table__group-toggle { width: 100%; padding: 0; border: 0; display: flex; align-items: center; gap: 10px; color: inherit; background: transparent; font: inherit; text-align: left; cursor: pointer; }
|
||||||
|
.inventory-table__group-toggle > span:nth-child(2) { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.inventory-table__group-toggle strong { overflow: hidden; color: #34433a; font-size: 11.5px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.inventory-table__group-toggle small { margin-top: 2px; color: #63806b; font-size: 9.5px; }
|
||||||
|
.inventory-table__group-icon { margin-left: auto; color: #6c8773; transition: transform 160ms ease; }
|
||||||
|
.inventory-table__group-toggle--collapsed .inventory-table__group-icon { transform: rotate(-90deg); }
|
||||||
|
.inventory-table__entry-row td { background: #fcfdfb; }
|
||||||
|
.inventory-table__entry-row td:first-child { border-left: 3px solid #d4e4d7; }
|
||||||
|
.inventory-table__item--nested { min-width: 170px; gap: 8px; }
|
||||||
|
.inventory-table__entry-mark { width: 34px; color: #78a081; font-size: 17px; font-weight: 700; text-align: center; }
|
||||||
|
.inventory-table__group-actions { color: #87948b; font-size: 9.5px; white-space: nowrap; }
|
||||||
|
|
||||||
|
/* Inventory cards */
|
||||||
|
.item-grid, .skeleton-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.item-card { min-width: 0; border: 1px solid #e1e5df; border-radius: 14px; overflow: hidden; background: #fff; box-shadow: var(--shadow-sm); transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; }
|
||||||
|
.item-card:hover { transform: translateY(-3px); border-color: #d0dacf; box-shadow: 0 15px 34px rgba(34, 56, 42, 0.11); }
|
||||||
|
.item-card__visual { position: relative; height: 154px; display: flex; align-items: center; justify-content: center; overflow: hidden; background: #edf3ed; }
|
||||||
|
.item-card__visual--blue { background: #e8f1f4; }
|
||||||
|
.item-card__visual--red { background: #f5ece8; }
|
||||||
|
.item-card__visual--gold { background: #f5f0e3; }
|
||||||
|
.item-card__fallback { color: rgba(54, 91, 67, 0.62); transform: translateY(6px); }
|
||||||
|
.item-card__visual--blue .item-card__fallback { color: #628192; }
|
||||||
|
.item-card__visual--red .item-card__fallback { color: #a66a58; }
|
||||||
|
.item-card__visual--gold .item-card__fallback { color: #a7844c; }
|
||||||
|
.item-card__visual img { position: absolute; inset: 13px; width: calc(100% - 26px); height: calc(100% - 26px); object-fit: contain; mix-blend-mode: multiply; }
|
||||||
|
.status-chip { position: absolute; z-index: 2; left: 10px; top: 10px; padding: 5px 8px; border-radius: 999px; color: #657168; background: rgba(255,255,255,0.9); font-size: 9.5px; font-weight: 700; box-shadow: 0 3px 10px rgba(41, 62, 49, 0.08); }
|
||||||
|
.status-chip--danger { color: #963d38; background: #fbe4e0; }
|
||||||
|
.status-chip--warning { color: #96611e; background: #fff0d4; }
|
||||||
|
.status-chip--good { color: #39704b; background: #e6f2e8; }
|
||||||
|
.item-card__actions { position: absolute; z-index: 3; top: 9px; right: 9px; display: flex; gap: 5px; opacity: 0; transform: translateY(-3px); transition: opacity 160ms ease, transform 160ms ease; }
|
||||||
|
.item-card:hover .item-card__actions, .item-card:focus-within .item-card__actions { opacity: 1; transform: translateY(0); }
|
||||||
|
.item-card__content { padding: 14px 15px 15px; }
|
||||||
|
.item-card__category { margin: 0 0 3px; overflow: hidden; color: #849087; font-size: 9px; font-weight: 700; letter-spacing: 0.8px; text-transform: uppercase; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.item-card h3 { margin: 0 0 9px; overflow: hidden; color: #2d3b32; font-size: 15px; letter-spacing: -0.3px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.item-card__meta { min-height: 33px; display: flex; flex-direction: column; gap: 3px; color: #89948d; font-size: 10px; }
|
||||||
|
.item-card__meta span { display: flex; align-items: center; gap: 4px; }
|
||||||
|
.item-card__date { margin-top: 10px; padding-top: 9px; border-top: 1px solid #edf0ec; display: flex; align-items: center; gap: 6px; color: #66756c; font-size: 10.5px; font-weight: 600; }
|
||||||
|
.add-item-card { min-height: 260px; padding: 20px; border: 1px dashed #bdcbbf; border-radius: 14px; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #6f8075; background: rgba(255,255,255,0.35); cursor: pointer; transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; }
|
||||||
|
.add-item-card:hover { border-color: #7fa087; color: var(--green); background: #f4f8f4; transform: translateY(-2px); }
|
||||||
|
.add-item-card > span { width: 43px; height: 43px; margin-bottom: 10px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--green-soft); }
|
||||||
|
.add-item-card strong { font-size: 12px; }
|
||||||
|
.add-item-card small { margin-top: 4px; color: #9aa49d; font-size: 9.5px; }
|
||||||
|
.skeleton-card { height: 264px; border-radius: 14px; background: linear-gradient(100deg, #eceeea 20%, #f6f7f4 40%, #eceeea 60%); background-size: 220% 100%; animation: shimmer 1.5s infinite linear; }
|
||||||
|
@keyframes shimmer { to { background-position-x: -220%; } }
|
||||||
|
|
||||||
|
/* Modal and scanner */
|
||||||
|
.modal-layer { position: fixed; inset: 0; z-index: 100; padding: 20px; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.modal-backdrop { position: absolute; inset: 0; width: 100%; height: 100%; padding: 0; border: 0; background: rgba(22, 33, 27, 0.52); backdrop-filter: blur(4px); cursor: default; animation: fadeIn 160ms ease both; }
|
||||||
|
.modal { position: relative; z-index: 1; width: min(100%, 540px); max-height: calc(100vh - 40px); border-radius: 17px; overflow: auto; background: #fff; box-shadow: var(--shadow-lg); animation: modalIn 180ms ease both; }
|
||||||
|
.modal--small { width: min(100%, 450px); }
|
||||||
|
.modal--large { width: min(100%, 850px); }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } }
|
||||||
|
@keyframes modalIn { from { opacity: 0; transform: translateY(8px) scale(0.985); } }
|
||||||
|
.modal__header { position: sticky; top: 0; z-index: 5; padding: 20px 22px 16px; border-bottom: 1px solid #e9ece7; display: flex; align-items: flex-start; justify-content: space-between; background: rgba(255,255,255,0.97); backdrop-filter: blur(8px); }
|
||||||
|
.modal__header h2 { margin: 0; color: #28382e; font-size: 20px; letter-spacing: -0.6px; }
|
||||||
|
.modal__body { padding: 20px 22px; display: grid; gap: 16px; }
|
||||||
|
.modal__footer { position: sticky; bottom: 0; z-index: 5; padding: 13px 22px; border-top: 1px solid #e9ece7; display: flex; align-items: center; justify-content: flex-end; gap: 8px; background: rgba(255,255,255,0.97); backdrop-filter: blur(8px); }
|
||||||
|
.confirm-dialog { padding: 24px 25px 17px; display: flex; align-items: flex-start; gap: 13px; }
|
||||||
|
.confirm-dialog p { margin: 2px 0 0; color: #68766d; font-size: 13px; line-height: 1.6; }
|
||||||
|
.confirm-dialog__icon { width: 40px; height: 40px; border-radius: 11px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; color: var(--danger); background: var(--danger-soft); }
|
||||||
|
.scanner-layout { grid-template-columns: 1.05fr 0.95fr; gap: 24px; }
|
||||||
|
.scanner-camera { min-width: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.camera-viewport { position: relative; min-height: 280px; border-radius: 14px; display: flex; align-items: center; justify-content: center; overflow: hidden; color: #c9d8ce; background: #1e2d24; }
|
||||||
|
.camera-viewport video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
.camera-viewport--captured { color: var(--green); background: #eaf3ec; }
|
||||||
|
.scanner-placeholder, .scanner-captured { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
|
||||||
|
.scanner-placeholder strong, .scanner-captured strong { margin-top: 12px; color: #eff6f1; font-size: 13px; }
|
||||||
|
.scanner-placeholder span { margin-top: 4px; color: #94a89b; font-size: 10.5px; }
|
||||||
|
.scanner-captured strong { color: #345b41; }
|
||||||
|
.scanner-captured span { margin-top: 6px; padding: 5px 9px; border-radius: 6px; color: #5f7666; background: rgba(255,255,255,.6); font-family: monospace; font-size: 11px; }
|
||||||
|
.scan-frame { position: absolute; z-index: 3; width: 72%; height: 46%; border: 2px solid rgba(222, 240, 226, 0.9); border-radius: 12px; box-shadow: 0 0 0 1000px rgba(13, 23, 17, .26); }
|
||||||
|
.scan-frame::before, .scan-frame::after { content: ''; position: absolute; width: 22px; height: 22px; border-color: #98d3a4; }
|
||||||
|
.scan-frame::before { left: -3px; top: -3px; border-left: 4px solid #98d3a4; border-top: 4px solid #98d3a4; border-radius: 8px 0 0; }
|
||||||
|
.scan-frame::after { right: -3px; bottom: -3px; border-right: 4px solid #98d3a4; border-bottom: 4px solid #98d3a4; border-radius: 0 0 8px; }
|
||||||
|
.scan-frame i { position: absolute; left: 4%; right: 4%; top: 15%; height: 2px; background: #9ce0a8; box-shadow: 0 0 9px #9ce0a8; animation: scanLine 2s ease-in-out infinite alternate; }
|
||||||
|
@keyframes scanLine { to { top: 84%; } }
|
||||||
|
.scanner-note { margin: 0; display: flex; align-items: flex-start; gap: 6px; color: #8a968e; font-size: 9.5px; line-height: 1.5; }
|
||||||
|
.scanner-note svg { flex-shrink: 0; color: var(--green); }
|
||||||
|
.scanner-note--error { color: #a24a43; }
|
||||||
|
.scanner-details { display: flex; flex-direction: column; gap: 13px; }
|
||||||
|
.scanner-details__heading { padding-bottom: 9px; border-bottom: 1px solid #e8ebe6; display: flex; flex-direction: column; }
|
||||||
|
.scanner-details__heading span { color: #34443a; font-size: 13px; font-weight: 700; }
|
||||||
|
.scanner-details__heading small { margin-top: 2px; color: #939d97; font-size: 9.5px; }
|
||||||
|
.scanner-wizard { max-width: 610px; margin-inline: auto; display: grid; gap: 18px; }
|
||||||
|
.scanner-steps { display: flex; align-items: center; gap: 13px; color: #a0aaa3; font-size: 10px; font-weight: 700; }
|
||||||
|
.scanner-steps__item { display: inline-flex; align-items: center; gap: 5px; }
|
||||||
|
.scanner-steps__item i { width: 19px; height: 19px; border: 1px solid #d8dfd9; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-style: normal; font-size: 9px; }
|
||||||
|
.scanner-steps__item--active { color: var(--green); }
|
||||||
|
.scanner-steps__item--active i { border-color: var(--green); color: #fff; background: var(--green); }
|
||||||
|
.scanner-product { padding: 13px 14px; border: 1px solid #cfe1d2; border-radius: 11px; display: flex; flex-direction: column; background: var(--green-soft); }
|
||||||
|
.scanner-product > span { color: #55735e; font-size: 9px; font-weight: 700; letter-spacing: .8px; text-transform: uppercase; }
|
||||||
|
.scanner-product strong { margin-top: 3px; color: #2e4d37; font-size: 14px; }
|
||||||
|
.scanner-product--missing { border-color: #ead7b3; background: #fff8ea; }
|
||||||
|
.scanner-product--missing > span, .scanner-product--missing strong { color: #82612c; }
|
||||||
|
.scanner-wizard__error { margin: 0 24px 16px; }
|
||||||
|
|
||||||
|
/* Empty states and notifications */
|
||||||
|
.empty-state { min-height: 330px; padding: 48px 25px; border: 1px dashed #d5dcd5; border-radius: 16px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; background: rgba(255,255,255,0.45); }
|
||||||
|
.empty-state__icon { width: 58px; height: 58px; margin-bottom: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; color: var(--green); background: var(--green-soft); }
|
||||||
|
.empty-state h3 { margin: 0 0 6px; color: #33443a; font-size: 17px; }
|
||||||
|
.empty-state p { max-width: 430px; margin: 0 0 18px; color: #849087; font-size: 12px; line-height: 1.55; }
|
||||||
|
.toast-stack { position: fixed; z-index: 200; right: 20px; bottom: 20px; width: min(380px, calc(100vw - 40px)); display: flex; flex-direction: column; gap: 9px; }
|
||||||
|
.toast { padding: 13px; border: 1px solid #dfe5df; border-radius: 12px; display: grid; grid-template-columns: auto 1fr auto; align-items: start; gap: 10px; color: var(--green); background: rgba(255,255,255,.96); box-shadow: var(--shadow-md); backdrop-filter: blur(10px); animation: toastIn 220ms ease both; }
|
||||||
|
.toast--error { color: var(--danger); }
|
||||||
|
.toast--info { color: var(--blue); }
|
||||||
|
.toast__copy { display: flex; flex-direction: column; }
|
||||||
|
.toast__copy strong { color: #344239; font-size: 12px; }
|
||||||
|
.toast__copy span { margin-top: 2px; color: #7f8b84; font-size: 10.5px; line-height: 1.45; }
|
||||||
|
@keyframes toastIn { from { opacity: 0; transform: translateY(8px); } }
|
||||||
|
.full-page-loader { min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 15px; color: #819087; font-size: 12px; }
|
||||||
|
.full-page-loader .loader-mark { animation: breathe 1.2s ease-in-out infinite alternate; }
|
||||||
|
@keyframes breathe { to { transform: scale(1.08); opacity: .72; } }
|
||||||
|
|
||||||
|
/* Management screens */
|
||||||
|
|
||||||
|
/* Locations */
|
||||||
|
.location-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||||
|
.location-card { min-height: 225px; padding: 20px; border: 1px solid #e0e5df; border-radius: 15px; display: flex; flex-direction: column; background: #fff; box-shadow: var(--shadow-sm); transition: transform 180ms ease, box-shadow 180ms ease, border-color 180ms ease; }
|
||||||
|
.location-card:not(.location-card--add):hover { transform: translateY(-2px); border-color: #d0dacf; box-shadow: var(--shadow-md); }
|
||||||
|
.location-card__icon { width: 52px; height: 52px; margin-bottom: 20px; border-radius: 14px; display: flex; align-items: center; justify-content: center; color: var(--green); background: var(--green-soft); }
|
||||||
|
.location-card:nth-child(3n + 2) .location-card__icon { color: #5c7181; background: var(--blue-soft); }
|
||||||
|
.location-card:nth-child(3n + 3) .location-card__icon { color: #9a713c; background: var(--warning-soft); }
|
||||||
|
.location-card__copy { flex: 1; }
|
||||||
|
.location-card__copy h2 { margin: 0 0 6px; color: #2d3d33; font-size: 18px; letter-spacing: -.5px; }
|
||||||
|
.location-card__copy > p:last-child { margin: 0; color: #7e8b83; font-size: 11.5px; line-height: 1.55; }
|
||||||
|
.location-card__actions { margin-top: 18px; padding-top: 13px; border-top: 1px solid #ebeee9; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.location-card--add { border-style: dashed; align-items: center; justify-content: center; color: #708177; background: rgba(255,255,255,.35); cursor: pointer; }
|
||||||
|
.location-card--add:hover { border-color: #83a28b; color: var(--green); background: #f2f7f3; }
|
||||||
|
.location-card--add > span { width: 44px; height: 44px; margin-bottom: 12px; border-radius: 50%; display: flex; align-items: center; justify-content: center; background: var(--green-soft); }
|
||||||
|
.location-card--add strong { font-size: 12.5px; }
|
||||||
|
.location-card--add small { margin-top: 4px; color: #9aa49d; font-size: 10px; }
|
||||||
|
|
||||||
|
/* Households */
|
||||||
|
.household-list { display: grid; gap: 18px; }
|
||||||
|
.household-card { border: 1px solid #dfe4de; border-radius: 16px; overflow: hidden; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.household-card__header { padding: 21px 22px 18px; display: grid; grid-template-columns: auto 1fr auto; align-items: start; gap: 15px; background: linear-gradient(120deg, #f8faf6, #fff); }
|
||||||
|
.household-card__mark { width: 48px; height: 48px; border-radius: 13px; display: flex; align-items: center; justify-content: center; color: var(--green); background: var(--green-soft); }
|
||||||
|
.household-card__title h2 { margin: 0 0 4px; color: #2d3d33; font-size: 19px; letter-spacing: -.6px; }
|
||||||
|
.household-card__title > p:last-child { margin: 0; color: #818d85; font-size: 11.5px; }
|
||||||
|
.role-badge { padding: 6px 9px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; color: #8c642c; background: var(--warning-soft); font-size: 9.5px; font-weight: 700; }
|
||||||
|
.household-card__body { padding: 17px 22px 20px; border-top: 1px solid #eaede9; }
|
||||||
|
.household-members-heading { margin-bottom: 12px; display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.household-members-heading > div { display: flex; align-items: center; gap: 8px; color: #526159; }
|
||||||
|
.household-members-heading strong { font-size: 11.5px; }
|
||||||
|
.household-members-heading button { padding: 0; border: 0; display: flex; align-items: center; gap: 6px; color: var(--green); background: transparent; font-size: 10.5px; font-weight: 700; cursor: pointer; }
|
||||||
|
.member-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; }
|
||||||
|
.member-row { min-width: 0; padding: 10px; border: 1px solid #e8ece7; border-radius: 11px; display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 9px; background: #fbfcfa; }
|
||||||
|
.member-row__identity { min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
.member-row__identity strong { overflow: hidden; color: #39483f; font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.member-row__identity small { margin-top: 2px; overflow: hidden; color: #929c96; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.member-role { display: flex; align-items: center; gap: 4px; color: #8d6938; font-size: 8.5px; font-weight: 700; }
|
||||||
|
.member-list__empty { padding: 13px; border-radius: 10px; display: flex; align-items: center; gap: 8px; color: #8c9891; background: #f5f7f4; font-size: 10px; }
|
||||||
|
.household-card__footer { padding: 11px 22px; border-top: 1px solid #eaede9; display: flex; align-items: center; justify-content: space-between; background: #fafbf9; }
|
||||||
|
.household-card__footer > span { color: #939d97; font-size: 9.5px; }
|
||||||
|
.household-card__footer > div { display: flex; gap: 5px; }
|
||||||
|
|
||||||
|
/* Users */
|
||||||
|
.admin-summary { padding: 9px 12px; border: 1px solid #e1e6e1; border-radius: 11px; display: flex; align-items: center; gap: 14px; background: #fff; }
|
||||||
|
.admin-summary span { display: flex; align-items: center; gap: 6px; color: #6f7e75; font-size: 10.5px; font-weight: 700; }
|
||||||
|
.admin-summary span + span { padding-left: 14px; border-left: 1px solid #e3e7e2; }
|
||||||
|
.admin-summary svg { color: var(--green); }
|
||||||
|
.user-toolbar { margin-bottom: 14px; display: flex; justify-content: flex-end; }
|
||||||
|
.user-toolbar .search-field { width: min(100%, 390px); background: #fff; }
|
||||||
|
.user-table-wrap { border: 1px solid #e0e5df; border-radius: 14px; overflow: hidden; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.user-table { width: 100%; border-collapse: collapse; }
|
||||||
|
.user-table th { padding: 12px 16px; color: #929c96; background: #fafbf9; font-size: 9px; font-weight: 700; letter-spacing: 1px; text-align: left; text-transform: uppercase; }
|
||||||
|
.user-table td { padding: 13px 16px; border-top: 1px solid #e9ece8; color: #617067; font-size: 11px; }
|
||||||
|
.user-table tr:hover td { background: #fcfdfb; }
|
||||||
|
.user-table th:last-child, .user-table td:last-child { text-align: right; }
|
||||||
|
.table-person { display: flex; align-items: center; gap: 10px; }
|
||||||
|
.table-person > span:last-child { display: flex; flex-direction: column; }
|
||||||
|
.table-person strong { color: #34433a; font-size: 11.5px; }
|
||||||
|
.table-person small { margin-top: 2px; color: #929d96; font-size: 9.5px; }
|
||||||
|
.you-badge { display: inline-flex; margin: 0 0 0 6px !important; padding: 2px 5px; border-radius: 4px; color: var(--green) !important; background: var(--green-soft); font-size: 8px !important; }
|
||||||
|
.role-label { width: fit-content; padding: 5px 8px; border-radius: 999px; display: inline-flex; align-items: center; gap: 5px; color: #6e7a72; background: #eff2ef; font-size: 9.5px; font-weight: 700; }
|
||||||
|
.role-label--admin { color: #345f44; background: var(--green-soft); }
|
||||||
|
.user-table code { color: #8c9790; font-size: 10px; }
|
||||||
|
.user-editor-heading { padding-bottom: 15px; border-bottom: 1px solid #eaede9; display: flex; align-items: center; gap: 12px; }
|
||||||
|
.user-editor-heading > div { display: flex; flex-direction: column; }
|
||||||
|
.user-editor-heading strong { color: #34433a; font-size: 13px; }
|
||||||
|
.user-editor-heading small { margin-top: 2px; color: #909a94; font-size: 10px; }
|
||||||
|
.role-toggle { position: relative; padding: 13px; border: 1px solid #dee5df; border-radius: 12px; display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 11px; cursor: pointer; }
|
||||||
|
.role-toggle__icon { width: 37px; height: 37px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: var(--green); background: var(--green-soft); }
|
||||||
|
.role-toggle > span:nth-child(2) { display: flex; flex-direction: column; }
|
||||||
|
.role-toggle strong { color: #38483e; font-size: 11.5px; }
|
||||||
|
.role-toggle small { margin-top: 2px; color: #929c96; font-size: 9.5px; }
|
||||||
|
.role-toggle input { position: absolute; opacity: 0; }
|
||||||
|
.role-toggle > i { position: relative; width: 38px; height: 22px; border-radius: 999px; background: #d6dcd7; transition: background 160ms ease; }
|
||||||
|
.role-toggle > i::after { content: ''; position: absolute; left: 3px; top: 3px; width: 16px; height: 16px; border-radius: 50%; background: #fff; box-shadow: 0 2px 5px rgba(30,45,35,.15); transition: transform 160ms ease; }
|
||||||
|
.role-toggle input:checked + i { background: var(--green); }
|
||||||
|
.role-toggle input:checked + i::after { transform: translateX(16px); }
|
||||||
|
|
||||||
|
/* Profile */
|
||||||
|
.profile-layout { display: grid; grid-template-columns: 270px minmax(0, 680px); align-items: start; gap: 20px; }
|
||||||
|
.profile-summary-card { padding: 28px 20px 21px; border: 1px solid #e0e5df; border-radius: 15px; display: flex; flex-direction: column; align-items: center; text-align: center; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.profile-summary-card h2 { margin: 14px 0 3px; color: #304037; font-size: 17px; }
|
||||||
|
.profile-summary-card > p { margin: 0 0 12px; color: #88948c; font-size: 10.5px; }
|
||||||
|
.profile-summary-card__meta { width: 100%; margin-top: 24px; padding-top: 16px; border-top: 1px solid #e9ece8; display: flex; flex-direction: column; align-items: flex-start; text-align: left; }
|
||||||
|
.profile-summary-card__meta span { color: #9aa39d; font-size: 8.5px; font-weight: 700; letter-spacing: .8px; text-transform: uppercase; }
|
||||||
|
.profile-summary-card__meta code { width: 100%; margin-top: 5px; overflow: hidden; color: #78857c; font-size: 9px; text-overflow: ellipsis; }
|
||||||
|
.settings-stack { display: grid; gap: 16px; }
|
||||||
|
.settings-card { border: 1px solid #e0e5df; border-radius: 15px; overflow: hidden; background: #fff; box-shadow: var(--shadow-sm); }
|
||||||
|
.settings-card > header { padding: 17px 20px; border-bottom: 1px solid #e9ece8; display: flex; align-items: center; gap: 11px; background: #fafbf9; }
|
||||||
|
.settings-card > header > span { width: 37px; height: 37px; border-radius: 10px; display: flex; align-items: center; justify-content: center; color: var(--green); background: var(--green-soft); }
|
||||||
|
.settings-card > header h2 { margin: 0 0 2px; color: #334339; font-size: 14px; }
|
||||||
|
.settings-card > header p { margin: 0; color: #929c96; font-size: 9.5px; }
|
||||||
|
.settings-card__body { padding: 20px; display: grid; gap: 15px; }
|
||||||
|
.settings-card form > footer { padding: 12px 20px; border-top: 1px solid #e9ece8; display: flex; justify-content: flex-end; background: #fafbf9; }
|
||||||
|
|
||||||
|
/* Responsive layout */
|
||||||
|
@media (max-width: 1180px) {
|
||||||
|
.item-grid, .skeleton-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
|
.location-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.summary-item { min-width: 105px; }
|
||||||
|
.inventory-toolbar { grid-template-columns: minmax(210px, 1fr) auto auto; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 920px) {
|
||||||
|
.auth-page { grid-template-columns: 1fr; }
|
||||||
|
.auth-story { display: none; }
|
||||||
|
.auth-panel { min-height: 100vh; padding: 55px 24px 75px; }
|
||||||
|
.auth-card::before { content: ''; width: 100%; height: 55px; margin-bottom: 35px; display: block; background: linear-gradient(120deg, transparent 45%, rgba(47,109,79,.04)); }
|
||||||
|
.sidebar { width: 218px; }
|
||||||
|
.app-main { margin-left: 218px; }
|
||||||
|
.page { padding-inline: 25px; }
|
||||||
|
.summary-strip { grid-template-columns: repeat(2, 1fr); gap: 15px; }
|
||||||
|
.summary-divider { display: none; }
|
||||||
|
.summary-item { min-width: 0; }
|
||||||
|
.item-grid, .skeleton-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
|
.scanner-layout { grid-template-columns: 1fr; }
|
||||||
|
.camera-viewport { min-height: 240px; }
|
||||||
|
.member-list { grid-template-columns: 1fr; }
|
||||||
|
.profile-layout { grid-template-columns: 220px minmax(0, 1fr); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.mobile-header { position: sticky; top: 0; z-index: 35; height: 60px; padding: 0 15px; border-bottom: 1px solid #e1e5df; display: flex; align-items: center; justify-content: space-between; background: rgba(251,251,247,.94); backdrop-filter: blur(12px); }
|
||||||
|
.mobile-header .brand__name { font-size: 20px; }
|
||||||
|
.sidebar { z-index: 60; width: min(285px, 86vw); transform: translateX(-102%); box-shadow: var(--shadow-lg); transition: transform 220ms ease; }
|
||||||
|
.sidebar--open { transform: translateX(0); }
|
||||||
|
.sidebar__close { display: inline-flex; }
|
||||||
|
.sidebar-backdrop { position: fixed; inset: 0; z-index: 55; width: 100%; height: 100%; padding: 0; border: 0; display: block; background: rgba(26,38,31,.42); backdrop-filter: blur(2px); }
|
||||||
|
.app-main { margin-left: 0; }
|
||||||
|
.page { padding: 28px 17px 60px; }
|
||||||
|
.page-header { margin-bottom: 23px; align-items: flex-start; flex-direction: column; }
|
||||||
|
.page-header h1 { font-size: 32px; }
|
||||||
|
.page-header__actions { width: 100%; }
|
||||||
|
.page-header__actions .button { flex: 1; }
|
||||||
|
.page--inventory { padding-bottom: 84px; }
|
||||||
|
.inventory-scan-button--header { display: none; }
|
||||||
|
.inventory-scan-button--floating { position: fixed; z-index: 36; left: max(17px, env(safe-area-inset-left)); bottom: max(17px, env(safe-area-inset-bottom)); width: 48px; min-height: 48px; padding: 0; border-radius: 50%; display: inline-flex; box-shadow: var(--shadow-md); }
|
||||||
|
.inventory-scan-button__label { display: none; }
|
||||||
|
.summary-strip { padding: 12px; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; justify-content: initial; }
|
||||||
|
.summary-item { justify-content: center; gap: 6px; }
|
||||||
|
.summary-item > span { width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0; }
|
||||||
|
.summary-item p { display: block; }
|
||||||
|
.summary-item strong { font-size: 18px; }
|
||||||
|
.summary-item small { display: none; }
|
||||||
|
.inventory-toolbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.search-field { grid-column: auto; }
|
||||||
|
.inventory-filter-button { min-width: 86px; height: 40px; padding: 0 11px; border: 1px solid #ccd8cf; border-radius: 9px; display: inline-flex; align-items: center; justify-content: center; gap: 6px; color: var(--green-dark); background: #fff; font-size: 12px; font-weight: 700; cursor: pointer; }
|
||||||
|
.inventory-filter-button:hover { border-color: #8aaa94; background: #f8fbf9; }
|
||||||
|
.inventory-toolbar__filters { grid-column: 1 / -1; display: none; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||||
|
.inventory-toolbar__filters--open { display: grid; }
|
||||||
|
.toolbar-select { min-width: 0; }
|
||||||
|
.item-grid, .skeleton-grid, .location-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 11px; }
|
||||||
|
.item-card__visual { height: 135px; }
|
||||||
|
.item-card__content { padding: 12px; }
|
||||||
|
.item-card__actions { opacity: 1; transform: none; }
|
||||||
|
.add-item-card { min-height: 242px; }
|
||||||
|
.modal-layer { padding: 0; align-items: flex-end; }
|
||||||
|
.modal { width: 100%; max-height: 94vh; border-radius: 20px 20px 0 0; }
|
||||||
|
.modal--small, .modal--large { width: 100%; }
|
||||||
|
.modal__header { padding-inline: 18px; }
|
||||||
|
.modal__body { padding-inline: 18px; }
|
||||||
|
.modal__footer { padding-inline: 18px; }
|
||||||
|
.profile-layout { grid-template-columns: 1fr; }
|
||||||
|
.profile-summary-card { padding-block: 22px; }
|
||||||
|
.profile-summary-card__meta { display: none; }
|
||||||
|
.admin-summary { width: 100%; justify-content: center; }
|
||||||
|
.user-toolbar { justify-content: stretch; }
|
||||||
|
.user-toolbar .search-field { width: 100%; }
|
||||||
|
.user-table, .user-table tbody, .user-table tr, .user-table td { display: block; }
|
||||||
|
.user-table thead { display: none; }
|
||||||
|
.user-table tr { position: relative; padding: 13px; border-top: 1px solid #e8ece7; }
|
||||||
|
.user-table tr:first-child { border-top: 0; }
|
||||||
|
.user-table td { padding: 4px 0; border: 0; }
|
||||||
|
.user-table td:nth-child(3) { display: none; }
|
||||||
|
.user-table td:last-child { position: absolute; right: 12px; bottom: 12px; }
|
||||||
|
.household-card__header { grid-template-columns: auto 1fr; }
|
||||||
|
.household-card__header .role-badge { grid-column: 1 / -1; width: fit-content; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.auth-panel { padding-inline: 19px; }
|
||||||
|
.auth-card__heading h2 { font-size: 29px; }
|
||||||
|
.form-row { grid-template-columns: 1fr; }
|
||||||
|
.form-row--quantity { grid-template-columns: .75fr 1.25fr; }
|
||||||
|
.page-header__actions { flex-direction: column-reverse; }
|
||||||
|
.summary-item { gap: 8px; }
|
||||||
|
.summary-item > span { width: 33px; height: 33px; }
|
||||||
|
.item-grid, .skeleton-grid { grid-template-columns: 1fr; }
|
||||||
|
.item-card__visual { height: 175px; }
|
||||||
|
.add-item-card { min-height: 175px; }
|
||||||
|
.location-grid { grid-template-columns: 1fr; }
|
||||||
|
.location-card { min-height: 205px; }
|
||||||
|
.inventory-toolbar { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.inventory-toolbar__filters { grid-template-columns: 1fr; }
|
||||||
|
.toolbar-select { width: 100%; }
|
||||||
|
.household-card__header, .household-card__body { padding-inline: 15px; }
|
||||||
|
.household-card__footer { padding-inline: 15px; align-items: flex-start; gap: 9px; flex-direction: column; }
|
||||||
|
.member-row { grid-template-columns: auto minmax(0, 1fr); }
|
||||||
|
.member-role { grid-column: 2; }
|
||||||
|
.scanner-layout { padding-top: 14px; }
|
||||||
|
.camera-viewport { min-height: 210px; }
|
||||||
|
.modal__footer .button { flex: 1; }
|
||||||
|
.settings-card__body, .settings-card > header { padding-inline: 15px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after { scroll-behavior: auto !important; animation-duration: .01ms !important; animation-iteration-count: 1 !important; transition-duration: .01ms !important; }
|
||||||
|
}
|
||||||
185
src/lib/api.ts
Normal file
185
src/lib/api.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import type {
|
||||||
|
AuthResponse,
|
||||||
|
BarcodeLookup,
|
||||||
|
Household,
|
||||||
|
InventoryItem,
|
||||||
|
InventoryItemRequest,
|
||||||
|
Location,
|
||||||
|
LoginRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
UpdateProfileRequest,
|
||||||
|
UpdateUserRequest,
|
||||||
|
User,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
|
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL ?? '').replace(/\/$/, '')
|
||||||
|
const ACCESS_TOKEN_KEY = 'keeply_access_token'
|
||||||
|
const REFRESH_TOKEN_KEY = 'keeply_refresh_token'
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number
|
||||||
|
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const tokenStore = {
|
||||||
|
getAccess: () => localStorage.getItem(ACCESS_TOKEN_KEY),
|
||||||
|
getRefresh: () => localStorage.getItem(REFRESH_TOKEN_KEY),
|
||||||
|
set(accessToken: string, refreshToken: string) {
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
localStorage.removeItem(ACCESS_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RequestOptions extends RequestInit {
|
||||||
|
authenticated?: boolean
|
||||||
|
retryOnUnauthorized?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getErrorMessage(response: Response): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as Record<string, unknown>
|
||||||
|
if (typeof data.message === 'string') return data.message
|
||||||
|
if (typeof data.error === 'string') return data.error
|
||||||
|
if (typeof data.title === 'string') return data.title
|
||||||
|
|
||||||
|
const errors = data.errors
|
||||||
|
if (errors && typeof errors === 'object') {
|
||||||
|
const first = Object.values(errors as Record<string, unknown[]>).flat()[0]
|
||||||
|
if (typeof first === 'string') return first
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// The API can legitimately return an empty error body.
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.statusText || 'Something went wrong. Please try again.'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAccessToken(): Promise<boolean> {
|
||||||
|
const accessToken = tokenStore.getAccess()
|
||||||
|
const refreshToken = tokenStore.getRefresh()
|
||||||
|
if (!accessToken || !refreshToken) return false
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/api/auth/refresh-token`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ accessToken, refreshToken }),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
tokenStore.clear()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as AuthResponse
|
||||||
|
if (!data.accessToken || !data.refreshToken) {
|
||||||
|
tokenStore.clear()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
tokenStore.set(data.accessToken, data.refreshToken)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
|
||||||
|
const {
|
||||||
|
authenticated = true,
|
||||||
|
retryOnUnauthorized = true,
|
||||||
|
headers: providedHeaders,
|
||||||
|
...fetchOptions
|
||||||
|
} = options
|
||||||
|
const headers = new Headers(providedHeaders)
|
||||||
|
if (fetchOptions.body && !(fetchOptions.body instanceof FormData)) {
|
||||||
|
headers.set('Content-Type', 'application/json')
|
||||||
|
}
|
||||||
|
if (authenticated) {
|
||||||
|
const token = tokenStore.getAccess()
|
||||||
|
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}${path}`, { ...fetchOptions, headers })
|
||||||
|
|
||||||
|
if (response.status === 401 && authenticated && retryOnUnauthorized) {
|
||||||
|
const refreshed = await refreshAccessToken()
|
||||||
|
if (refreshed) {
|
||||||
|
return apiRequest<T>(path, { ...options, retryOnUnauthorized: false })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(await getErrorMessage(response), response.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 204) return undefined as T
|
||||||
|
return (await response.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonBody(value: unknown) {
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
login: (request: LoginRequest) =>
|
||||||
|
apiRequest<AuthResponse>('/api/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: jsonBody(request),
|
||||||
|
authenticated: false,
|
||||||
|
}),
|
||||||
|
register: (request: RegisterRequest) =>
|
||||||
|
apiRequest<AuthResponse>('/api/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
body: jsonBody(request),
|
||||||
|
authenticated: false,
|
||||||
|
}),
|
||||||
|
logout: () => apiRequest<void>('/api/auth/logout', { method: 'POST' }),
|
||||||
|
getProfile: () => apiRequest<User>('/api/profile'),
|
||||||
|
updateProfile: (request: UpdateProfileRequest) =>
|
||||||
|
apiRequest<User>('/api/profile', { method: 'PUT', body: jsonBody(request) }),
|
||||||
|
|
||||||
|
getItems: () => apiRequest<InventoryItem[]>('/api/inventoryitems'),
|
||||||
|
searchItems: (query: string) =>
|
||||||
|
apiRequest<InventoryItem[]>(`/api/search/items?q=${encodeURIComponent(query)}`),
|
||||||
|
lookupBarcode: (barcode: string) =>
|
||||||
|
apiRequest<BarcodeLookup>(`/api/inventoryitems/barcode/${encodeURIComponent(barcode)}`),
|
||||||
|
createItem: (request: InventoryItemRequest) =>
|
||||||
|
apiRequest<{ id: string }>('/api/inventoryitems', { method: 'POST', body: jsonBody(request) }),
|
||||||
|
updateItem: (id: string, request: InventoryItemRequest) =>
|
||||||
|
apiRequest<InventoryItem>(`/api/inventoryitems/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: jsonBody(request),
|
||||||
|
}),
|
||||||
|
deleteItem: (id: string) =>
|
||||||
|
apiRequest<void>(`/api/inventoryitems/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getLocations: () => apiRequest<Location[]>('/api/locations'),
|
||||||
|
createLocation: (request: Pick<Location, 'name' | 'description'>) =>
|
||||||
|
apiRequest<Location>('/api/locations', { method: 'POST', body: jsonBody(request) }),
|
||||||
|
updateLocation: (id: string, request: Pick<Location, 'name' | 'description'>) =>
|
||||||
|
apiRequest<Location>(`/api/locations/${id}`, { method: 'PUT', body: jsonBody(request) }),
|
||||||
|
deleteLocation: (id: string) => apiRequest<void>(`/api/locations/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getHouseholds: () => apiRequest<Household[]>('/api/households'),
|
||||||
|
createHousehold: (request: Pick<Household, 'name' | 'description'>) =>
|
||||||
|
apiRequest<Household>('/api/households', { method: 'POST', body: jsonBody(request) }),
|
||||||
|
updateHousehold: (id: string, request: Pick<Household, 'name' | 'description'>) =>
|
||||||
|
apiRequest<Household>(`/api/households/${id}`, { method: 'PUT', body: jsonBody(request) }),
|
||||||
|
inviteHouseholdMember: (id: string, email: string) =>
|
||||||
|
apiRequest<Household>(`/api/households/${id}/invite`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: jsonBody({ email }),
|
||||||
|
}),
|
||||||
|
leaveHousehold: (id: string) =>
|
||||||
|
apiRequest<void>(`/api/households/${id}/leave`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
getUsers: () => apiRequest<User[]>('/api/users'),
|
||||||
|
updateUser: (id: string, request: UpdateUserRequest) =>
|
||||||
|
apiRequest<User>(`/api/users/${id}`, { method: 'PUT', body: jsonBody(request) }),
|
||||||
|
}
|
||||||
81
src/lib/format.ts
Normal file
81
src/lib/format.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import type { InventoryItem, ProductImage } from '../types'
|
||||||
|
|
||||||
|
const DAY_MS = 86_400_000
|
||||||
|
|
||||||
|
export function parseDate(value?: string | null): Date | null {
|
||||||
|
if (!value) return null
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntil(value?: string | null): number | null {
|
||||||
|
const date = parseDate(value)
|
||||||
|
if (!date) return null
|
||||||
|
const today = new Date()
|
||||||
|
today.setHours(0, 0, 0, 0)
|
||||||
|
date.setHours(0, 0, 0, 0)
|
||||||
|
return Math.ceil((date.getTime() - today.getTime()) / DAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expiryLabel(value?: string | null): string {
|
||||||
|
const days = daysUntil(value)
|
||||||
|
if (days === null) return 'No expiry set'
|
||||||
|
if (days < 0) return `Expired ${Math.abs(days)}d ago`
|
||||||
|
if (days === 0) return 'Expires today'
|
||||||
|
if (days === 1) return 'Expires tomorrow'
|
||||||
|
return `Expires in ${days} days`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expiryTone(value?: string | null): 'danger' | 'warning' | 'good' | 'neutral' {
|
||||||
|
const days = daysUntil(value)
|
||||||
|
if (days === null) return 'neutral'
|
||||||
|
if (days < 0) return 'danger'
|
||||||
|
if (days <= 3) return 'warning'
|
||||||
|
return 'good'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(value?: string | null): string {
|
||||||
|
const date = parseDate(value)
|
||||||
|
if (!date) return 'Not set'
|
||||||
|
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'short', year: 'numeric' }).format(date)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDateInput(value?: string | null): string {
|
||||||
|
if (!value) return ''
|
||||||
|
return value.slice(0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toApiDate(value: string): string | undefined {
|
||||||
|
return value ? `${value}T00:00:00` : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayName(firstName?: string | null, lastName?: string | null, email?: string) {
|
||||||
|
const name = [firstName, lastName].filter(Boolean).join(' ')
|
||||||
|
return name || email || 'Keeply user'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function itemImage(item: InventoryItem): string | null {
|
||||||
|
const images = item.itemLookupImages
|
||||||
|
if (!Array.isArray(images)) return null
|
||||||
|
const first = images[0] as ProductImage | string | undefined
|
||||||
|
if (typeof first === 'string') return first
|
||||||
|
if (!first || typeof first !== 'object') return null
|
||||||
|
const url = first.url ?? first.image_url
|
||||||
|
return typeof url === 'string' ? url : null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function productSize(value: unknown): string | null {
|
||||||
|
if (typeof value === 'string' || typeof value === 'number') return String(value)
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
const record = value as Record<string, unknown>
|
||||||
|
const size = record.value ?? record.display ?? record.text
|
||||||
|
if (typeof size === 'string' || typeof size === 'number') return String(size)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getInitials(firstName?: string | null, lastName?: string | null, email?: string) {
|
||||||
|
const letters = [firstName, lastName].filter(Boolean).map((part) => String(part)[0])
|
||||||
|
if (letters.length) return letters.join('').toUpperCase()
|
||||||
|
return (email?.[0] ?? 'K').toUpperCase()
|
||||||
|
}
|
||||||
19
src/main.tsx
Normal file
19
src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { BrowserRouter } from 'react-router-dom'
|
||||||
|
import App from './App'
|
||||||
|
import { AuthProvider } from './context/AuthContext'
|
||||||
|
import { ToastProvider } from './context/ToastContext'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter>
|
||||||
|
<ToastProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<App />
|
||||||
|
</AuthProvider>
|
||||||
|
</ToastProvider>
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
139
src/pages/AuthPage.tsx
Normal file
139
src/pages/AuthPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import { ArrowRight, Check, Eye, EyeOff, LoaderCircle, ScanBarcode, ShieldCheck, Sparkles } from 'lucide-react'
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { Logo } from '../components/Logo'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
|
||||||
|
export function AuthPage() {
|
||||||
|
const { login, register } = useAuth()
|
||||||
|
const [mode, setMode] = useState<'login' | 'register'>('login')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
const form = new FormData(event.currentTarget)
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (mode === 'login') {
|
||||||
|
await login({
|
||||||
|
email: String(form.get('email') ?? ''),
|
||||||
|
password: String(form.get('password') ?? ''),
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const password = String(form.get('password') ?? '')
|
||||||
|
const confirmPassword = String(form.get('confirmPassword') ?? '')
|
||||||
|
if (password !== confirmPassword) throw new Error('The passwords do not match.')
|
||||||
|
await register({
|
||||||
|
email: String(form.get('email') ?? ''),
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
firstName: String(form.get('firstName') ?? ''),
|
||||||
|
lastName: String(form.get('lastName') ?? ''),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Something went wrong. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const switchMode = () => {
|
||||||
|
setMode((current) => (current === 'login' ? 'register' : 'login'))
|
||||||
|
setError('')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="auth-page">
|
||||||
|
<section className="auth-story">
|
||||||
|
<div className="auth-story__top">
|
||||||
|
<Logo />
|
||||||
|
<span className="auth-story__pill"><Sparkles size={14} /> Food, thoughtfully kept</span>
|
||||||
|
</div>
|
||||||
|
<div className="auth-story__content">
|
||||||
|
<p className="eyebrow eyebrow--light">A calmer kitchen starts here</p>
|
||||||
|
<h1>Know what you have.<br /><em>Use it beautifully.</em></h1>
|
||||||
|
<p className="auth-story__lead">
|
||||||
|
Keep your pantry organised, waste less food, and share the load with everyone at home.
|
||||||
|
</p>
|
||||||
|
<div className="auth-benefits">
|
||||||
|
<div><span><Check size={16} /></span><p><strong>See every item at a glance</strong>Clear expiry cues make planning simple.</p></div>
|
||||||
|
<div><span><ScanBarcode size={17} /></span><p><strong>Scan and store in seconds</strong>Product details are handled for you.</p></div>
|
||||||
|
<div><span><ShieldCheck size={17} /></span><p><strong>Made for the whole household</strong>Manage people, places and shared routines.</p></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="auth-story__quote">“A small daily ritual that makes the whole kitchen feel lighter.”</p>
|
||||||
|
<div className="auth-story__orb auth-story__orb--one" />
|
||||||
|
<div className="auth-story__orb auth-story__orb--two" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="auth-panel">
|
||||||
|
<div className="auth-card">
|
||||||
|
<div className="auth-card__heading">
|
||||||
|
<p className="eyebrow">{mode === 'login' ? 'Welcome back' : 'Your pantry awaits'}</p>
|
||||||
|
<h2>{mode === 'login' ? 'Sign in to Keeply' : 'Create your account'}</h2>
|
||||||
|
<p>{mode === 'login' ? 'Pick up right where you left off.' : 'Set up your kitchen in a few moments.'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)} className="auth-form">
|
||||||
|
{mode === 'register' && (
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="field">
|
||||||
|
<span>First name</span>
|
||||||
|
<input name="firstName" autoComplete="given-name" placeholder="Alex" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Last name</span>
|
||||||
|
<input name="lastName" autoComplete="family-name" placeholder="Morgan" required />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<label className="field">
|
||||||
|
<span>Email address</span>
|
||||||
|
<input name="email" type="email" autoComplete="email" placeholder="you@example.com" required />
|
||||||
|
</label>
|
||||||
|
<label className="field">
|
||||||
|
<span>Password</span>
|
||||||
|
<div className="input-with-action">
|
||||||
|
<input
|
||||||
|
name="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
autoComplete={mode === 'login' ? 'current-password' : 'new-password'}
|
||||||
|
placeholder="At least 8 characters"
|
||||||
|
minLength={8}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button type="button" onClick={() => setShowPassword((current) => !current)} aria-label={showPassword ? 'Hide password' : 'Show password'}>
|
||||||
|
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
{mode === 'register' && (
|
||||||
|
<label className="field">
|
||||||
|
<span>Confirm password</span>
|
||||||
|
<input name="confirmPassword" type={showPassword ? 'text' : 'password'} autoComplete="new-password" placeholder="Repeat your password" minLength={8} required />
|
||||||
|
<small>Use upper and lowercase letters and at least one number.</small>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
|
||||||
|
<button className="button button--primary button--wide button--large" type="submit" disabled={loading}>
|
||||||
|
{loading ? <LoaderCircle className="spinner" size={19} /> : <>{mode === 'login' ? 'Sign in' : 'Create account'}<ArrowRight size={18} /></>}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="auth-card__switch">
|
||||||
|
{mode === 'login' ? 'New to Keeply?' : 'Already have an account?'}{' '}
|
||||||
|
<button type="button" onClick={switchMode}>{mode === 'login' ? 'Create an account' : 'Sign in'}</button>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="auth-panel__footer">Private by design · Powered by your pantry API</p>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
src/pages/HouseholdsPage.tsx
Normal file
195
src/pages/HouseholdsPage.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { Building2, CircleAlert, Crown, Edit3, LogOut, MailPlus, Plus, ShieldCheck, UserRound, UsersRound } from 'lucide-react'
|
||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ConfirmDialog, EmptyState, Modal, PageSkeleton, Spinner } from '../components/ui'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { displayName, formatDate, getInitials } from '../lib/format'
|
||||||
|
import type { Household } from '../types'
|
||||||
|
|
||||||
|
function HouseholdEditor({ household, onClose, onSaved }: { household?: Household | null; onClose: () => void; onSaved: (household: Household) => void }) {
|
||||||
|
const [name, setName] = useState(household?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(household?.description ?? '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const request = { name: name.trim(), description: description.trim() || null }
|
||||||
|
const saved = household ? await api.updateHousehold(household.id, request) : await api.createHousehold(request)
|
||||||
|
onSaved(saved)
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'The household could not be saved.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={household ? 'Edit household' : 'Create a household'} eyebrow="Shared pantry" size="small">
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<div className="modal__body">
|
||||||
|
<label className="field"><span>Household name</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. The Morgan household" required autoFocus /></label>
|
||||||
|
<label className="field"><span>Description <small>optional</small></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="A short note for your household" rows={4} /></label>
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer"><button type="button" className="button button--ghost" onClick={onClose} disabled={saving}>Cancel</button><button className="button button--primary" disabled={saving || !name.trim()}>{saving && <Spinner />}{household ? 'Save changes' : 'Create household'}</button></div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InviteMemberModal({ household, onClose, onSaved }: { household: Household; onClose: () => void; onSaved: (household: Household) => void }) {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const saved = await api.inviteHouseholdMember(household.id, email.trim())
|
||||||
|
onSaved(saved)
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'This member could not be added.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title="Add a household member" eyebrow={household.name} size="small">
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<div className="modal__body">
|
||||||
|
<div className="form-intro"><span><MailPlus size={21} /></span><p><strong>Invite an existing Keeply user</strong>Enter the email address they use to sign in.</p></div>
|
||||||
|
<label className="field"><span>Email address</span><input type="email" value={email} onChange={(event) => setEmail(event.target.value)} placeholder="person@example.com" required autoFocus /></label>
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer"><button type="button" className="button button--ghost" onClick={onClose} disabled={saving}>Cancel</button><button className="button button--primary" disabled={saving || !email.trim()}>{saving && <Spinner />}Add member</button></div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HouseholdsPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [households, setHouseholds] = useState<Household[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Household | null>(null)
|
||||||
|
const [inviting, setInviting] = useState<Household | null>(null)
|
||||||
|
const [leaving, setLeaving] = useState<Household | null>(null)
|
||||||
|
const [leaveLoading, setLeaveLoading] = useState(false)
|
||||||
|
const isSiteAdmin = user?.roles.some((role) => role === 'Site Admin' || role === 'Admin') ?? false
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setHouseholds(await api.getHouseholds())
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Households could not be loaded.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
const upsertHousehold = (saved: Household) => {
|
||||||
|
setHouseholds((current) => current.some((household) => household.id === saved.id)
|
||||||
|
? current.map((household) => household.id === saved.id ? saved : household)
|
||||||
|
: [...current, saved])
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHouseholdSaved = (saved: Household) => {
|
||||||
|
upsertHousehold(saved)
|
||||||
|
showToast('success', editing ? 'Household updated' : 'Household created', `${saved.name} is ready.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMemberAdded = (saved: Household) => {
|
||||||
|
upsertHousehold(saved)
|
||||||
|
showToast('success', 'Member added', `The member now has access to ${saved.name}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLeave = async () => {
|
||||||
|
if (!leaving) return
|
||||||
|
setLeaveLoading(true)
|
||||||
|
try {
|
||||||
|
await api.leaveHousehold(leaving.id)
|
||||||
|
setHouseholds((current) => current.filter((household) => household.id !== leaving.id))
|
||||||
|
showToast('success', 'Household left', `You left ${leaving.name}.`)
|
||||||
|
setLeaving(null)
|
||||||
|
} catch (caught) {
|
||||||
|
showToast('error', 'Could not leave household', caught instanceof Error ? caught.message : undefined)
|
||||||
|
} finally {
|
||||||
|
setLeaveLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div><p className="eyebrow">Pantries work better together</p><h1>Households</h1><p>Manage shared spaces and the people who keep them running.</p></div>
|
||||||
|
{isSiteAdmin && <button className="button button--primary" onClick={() => { setEditing(null); setEditorOpen(true) }}><Plus size={18} />New household</button>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!isSiteAdmin && <div className="info-banner"><ShieldCheck size={19} /><div><strong>Your household access is managed safely</strong><span>Household admins can update details and add existing Keeply users.</span></div></div>}
|
||||||
|
|
||||||
|
{loading ? <PageSkeleton cards={3} /> : error ? (
|
||||||
|
<EmptyState icon={CircleAlert} title="Households are unavailable" message={error} action={<button className="button button--secondary" onClick={() => void load()}>Try again</button>} />
|
||||||
|
) : households.length ? (
|
||||||
|
<section className="household-list">
|
||||||
|
{households.map((household) => {
|
||||||
|
const canManage = isSiteAdmin || household.isCurrentUserHouseholdAdmin
|
||||||
|
return (
|
||||||
|
<article className="household-card" key={household.id}>
|
||||||
|
<header className="household-card__header">
|
||||||
|
<div className="household-card__mark"><Building2 size={25} /></div>
|
||||||
|
<div className="household-card__title"><p className="eyebrow">Established {formatDate(household.createdAt)}</p><h2>{household.name}</h2><p>{household.description || 'A shared place for a well-kept kitchen.'}</p></div>
|
||||||
|
{household.isCurrentUserHouseholdAdmin && <span className="role-badge"><Crown size={14} />You manage this</span>}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="household-card__body">
|
||||||
|
<div className="household-members-heading"><div><UsersRound size={18} /><strong>{household.members.length} {household.members.length === 1 ? 'member' : 'members'}</strong></div>{canManage && <button onClick={() => setInviting(household)}><MailPlus size={16} />Add member</button>}</div>
|
||||||
|
<div className="member-list">
|
||||||
|
{household.members.map((member) => (
|
||||||
|
<div className="member-row" key={member.userId}>
|
||||||
|
<span className="avatar avatar--member">{getInitials(member.firstName, member.lastName, member.email)}</span>
|
||||||
|
<span className="member-row__identity"><strong>{displayName(member.firstName, member.lastName, member.email)}</strong><small>{member.email}</small></span>
|
||||||
|
{member.isHouseholdAdmin && <span className="member-role"><Crown size={13} />Household admin</span>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!household.members.length && <div className="member-list__empty"><UserRound size={18} />No members have been added yet.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="household-card__footer">
|
||||||
|
<span>Managed by {household.adminEmail}</span>
|
||||||
|
<div>
|
||||||
|
{canManage && <button className="button button--soft button--small" onClick={() => { setEditing(household); setEditorOpen(true) }}><Edit3 size={15} />Edit</button>}
|
||||||
|
{!household.isCurrentUserHouseholdAdmin && <button className="button button--ghost button--small button--text-danger" onClick={() => setLeaving(household)}><LogOut size={15} />Leave</button>}
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptyState icon={Building2} title="No households yet" message={isSiteAdmin ? 'Create a household, then add the people who share it.' : 'A site administrator can create a household and add you to it.'} action={isSiteAdmin ? <button className="button button--primary" onClick={() => setEditorOpen(true)}><Plus size={18} />Create household</button> : undefined} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editorOpen && <HouseholdEditor household={editing} onClose={() => setEditorOpen(false)} onSaved={handleHouseholdSaved} />}
|
||||||
|
{inviting && <InviteMemberModal household={inviting} onClose={() => setInviting(null)} onSaved={handleMemberAdded} />}
|
||||||
|
<ConfirmDialog open={Boolean(leaving)} title="Leave this household?" message={`You will lose access to ${leaving?.name ?? 'this household'} and its shared search results.`} confirmLabel="Leave household" loading={leaveLoading} onConfirm={() => void handleLeave()} onClose={() => setLeaving(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
366
src/pages/InventoryPage.tsx
Normal file
366
src/pages/InventoryPage.tsx
Normal file
@@ -0,0 +1,366 @@
|
|||||||
|
import {
|
||||||
|
Apple,
|
||||||
|
ArrowDown,
|
||||||
|
ArrowDownAZ,
|
||||||
|
ArrowUp,
|
||||||
|
ChevronDown,
|
||||||
|
CircleAlert,
|
||||||
|
Clock3,
|
||||||
|
Edit3,
|
||||||
|
MapPin,
|
||||||
|
PackageOpen,
|
||||||
|
Plus,
|
||||||
|
ScanBarcode,
|
||||||
|
Search,
|
||||||
|
SlidersHorizontal,
|
||||||
|
Trash2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { createPortal } from 'react-dom'
|
||||||
|
import { BarcodeScannerModal } from '../components/BarcodeScannerModal'
|
||||||
|
import { InventoryItemModal } from '../components/InventoryItemModal'
|
||||||
|
import { ConfirmDialog, EmptyState, PageSkeleton } from '../components/ui'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { daysUntil, expiryLabel, expiryTone, formatDate, itemImage, productSize } from '../lib/format'
|
||||||
|
import type { InventoryItem, Location } from '../types'
|
||||||
|
|
||||||
|
type StatusFilter = 'all' | 'expired' | 'expiring' | 'fresh' | 'no-expiry'
|
||||||
|
type SortColumn = 'name' | 'quantity' | 'location' | 'expiry' | 'status'
|
||||||
|
type SortDirection = 'asc' | 'desc'
|
||||||
|
|
||||||
|
function itemTitle(item: InventoryItem) {
|
||||||
|
return item.itemLookupTitle || item.name
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusOrder(item: InventoryItem) {
|
||||||
|
const days = daysUntil(item.expiryDate)
|
||||||
|
if (days === null) return 3
|
||||||
|
if (days < 0) return 0
|
||||||
|
if (days <= 7) return 1
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemGroup {
|
||||||
|
key: string
|
||||||
|
items: InventoryItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function inventoryGroupKey(item: InventoryItem) {
|
||||||
|
if (item.itemLookupId) return `lookup:${item.itemLookupId}`
|
||||||
|
return `name:${itemTitle(item).trim().toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function expiryTimestamp(item: InventoryItem) {
|
||||||
|
return item.expiryDate ? new Date(item.expiryDate).getTime() : Number.MAX_SAFE_INTEGER
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupQuantity(items: InventoryItem[]) {
|
||||||
|
return items.reduce((total, item) => total + (item.amount ?? 1), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function groupLocationLabel(items: InventoryItem[]) {
|
||||||
|
const locationNames = [...new Set(items.map((item) => item.location?.name || 'Not placed'))]
|
||||||
|
return locationNames.length === 1 ? locationNames[0] : `${locationNames.length} locations`
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryItemRow({
|
||||||
|
item,
|
||||||
|
entryNumber,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
item: InventoryItem
|
||||||
|
entryNumber?: number
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const image = itemImage(item)
|
||||||
|
const size = productSize(item.itemLookupSize)
|
||||||
|
const isNested = entryNumber !== undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr className={isNested ? 'inventory-table__entry-row' : undefined}>
|
||||||
|
<td>
|
||||||
|
{isNested ? (
|
||||||
|
<div className="inventory-table__item inventory-table__item--nested">
|
||||||
|
<span className="inventory-table__entry-mark" aria-hidden="true">↳</span>
|
||||||
|
<span><strong>Entry {entryNumber}</strong><small>{item.barcode ? `Barcode ${item.barcode}` : 'Individual pantry entry'}</small></span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="inventory-table__item">
|
||||||
|
<span className="inventory-table__image"><PackageOpen size={18} strokeWidth={1.5} />{image && <img src={image} alt="" onError={(event) => { event.currentTarget.style.display = 'none' }} />}</span>
|
||||||
|
<span><strong>{itemTitle(item)}</strong>{item.itemLookupTitle && item.name !== item.itemLookupTitle && <small>{item.name}</small>}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{item.amount ?? 1} {item.amountType || 'item'}{size ? <small className="inventory-table__detail"> · {size}</small> : null}</td>
|
||||||
|
<td>{item.location?.name || 'Not placed'}</td>
|
||||||
|
<td>{formatDate(item.expiryDate)}</td>
|
||||||
|
<td><span className={`status-chip status-chip--${expiryTone(item.expiryDate)}`}>{expiryLabel(item.expiryDate)}</span></td>
|
||||||
|
<td>
|
||||||
|
<div className="inventory-table__actions">
|
||||||
|
<button className="icon-button icon-button--small" onClick={onEdit} aria-label={`Edit ${itemTitle(item)}`}><Edit3 size={15} /></button>
|
||||||
|
<button className="icon-button icon-button--small icon-button--danger" onClick={onDelete} aria-label={`Delete ${itemTitle(item)}`}><Trash2 size={15} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function InventoryPage() {
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [items, setItems] = useState<InventoryItem[]>([])
|
||||||
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [loadError, setLoadError] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
|
||||||
|
const [locationFilter, setLocationFilter] = useState('all')
|
||||||
|
const [filtersOpen, setFiltersOpen] = useState(false)
|
||||||
|
const [sortColumn, setSortColumn] = useState<SortColumn>('expiry')
|
||||||
|
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
|
||||||
|
const [itemEditorOpen, setItemEditorOpen] = useState(false)
|
||||||
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
|
||||||
|
const [scannerOpen, setScannerOpen] = useState(false)
|
||||||
|
const [editingItem, setEditingItem] = useState<InventoryItem | null>(null)
|
||||||
|
const [deletingItem, setDeletingItem] = useState<InventoryItem | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState(false)
|
||||||
|
|
||||||
|
const loadData = useCallback(async (showLoader = false) => {
|
||||||
|
if (showLoader) setLoading(true)
|
||||||
|
setLoadError('')
|
||||||
|
try {
|
||||||
|
const [inventory, availableLocations] = await Promise.all([api.getItems(), api.getLocations()])
|
||||||
|
setItems(inventory)
|
||||||
|
setLocations(availableLocations)
|
||||||
|
} catch (caught) {
|
||||||
|
setLoadError(caught instanceof Error ? caught.message : 'Your pantry could not be loaded.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void loadData() }, [loadData])
|
||||||
|
|
||||||
|
const summary = useMemo(() => {
|
||||||
|
const expired = items.filter((item) => (daysUntil(item.expiryDate) ?? 0) < 0).length
|
||||||
|
const expiring = items.filter((item) => {
|
||||||
|
const days = daysUntil(item.expiryDate)
|
||||||
|
return days !== null && days >= 0 && days <= 7
|
||||||
|
}).length
|
||||||
|
return { expired, expiring, fresh: items.length - expired - expiring }
|
||||||
|
}, [items])
|
||||||
|
|
||||||
|
const filteredItems = useMemo(() => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase()
|
||||||
|
const matches = items.filter((item) => {
|
||||||
|
const days = daysUntil(item.expiryDate)
|
||||||
|
const matchesQuery = !normalizedQuery || [item.name, item.itemLookupTitle, item.barcode, item.location?.name]
|
||||||
|
.some((value) => value?.toLowerCase().includes(normalizedQuery))
|
||||||
|
const matchesLocation = locationFilter === 'all' || item.locationId === locationFilter
|
||||||
|
const matchesStatus = statusFilter === 'all'
|
||||||
|
|| (statusFilter === 'expired' && days !== null && days < 0)
|
||||||
|
|| (statusFilter === 'expiring' && days !== null && days >= 0 && days <= 7)
|
||||||
|
|| (statusFilter === 'fresh' && days !== null && days > 7)
|
||||||
|
|| (statusFilter === 'no-expiry' && days === null)
|
||||||
|
return matchesQuery && matchesLocation && matchesStatus
|
||||||
|
})
|
||||||
|
|
||||||
|
return matches
|
||||||
|
}, [items, query, locationFilter, statusFilter])
|
||||||
|
|
||||||
|
const groupedItems = useMemo(() => {
|
||||||
|
const groups = new Map<string, ItemGroup>()
|
||||||
|
|
||||||
|
filteredItems.forEach((item) => {
|
||||||
|
const key = inventoryGroupKey(item)
|
||||||
|
const group = groups.get(key)
|
||||||
|
if (group) group.items.push(item)
|
||||||
|
else groups.set(key, { key, items: [item] })
|
||||||
|
})
|
||||||
|
|
||||||
|
return [...groups.values()]
|
||||||
|
.map((group) => ({ ...group, items: [...group.items].sort((a, b) => expiryTimestamp(a) - expiryTimestamp(b)) }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aFirst = a.items[0]
|
||||||
|
const bFirst = b.items[0]
|
||||||
|
let comparison = 0
|
||||||
|
|
||||||
|
if (sortColumn === 'name') comparison = itemTitle(aFirst).localeCompare(itemTitle(bFirst))
|
||||||
|
if (sortColumn === 'quantity') comparison = groupQuantity(a.items) - groupQuantity(b.items)
|
||||||
|
if (sortColumn === 'location') comparison = groupLocationLabel(a.items).localeCompare(groupLocationLabel(b.items))
|
||||||
|
if (sortColumn === 'status') comparison = statusOrder(aFirst) - statusOrder(bFirst)
|
||||||
|
if (sortColumn === 'expiry') comparison = expiryTimestamp(aFirst) - expiryTimestamp(bFirst)
|
||||||
|
|
||||||
|
return sortDirection === 'asc' ? comparison : -comparison
|
||||||
|
})
|
||||||
|
}, [filteredItems, sortColumn, sortDirection])
|
||||||
|
|
||||||
|
const setSort = (column: SortColumn) => {
|
||||||
|
if (column === sortColumn) {
|
||||||
|
setSortDirection((direction) => direction === 'asc' ? 'desc' : 'asc')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSortColumn(column)
|
||||||
|
setSortDirection('asc')
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortIcon = (column: SortColumn) => {
|
||||||
|
if (column !== sortColumn) return <ArrowDownAZ size={13} aria-hidden="true" />
|
||||||
|
return sortDirection === 'asc' ? <ArrowUp size={13} aria-hidden="true" /> : <ArrowDown size={13} aria-hidden="true" />
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortState = (column: SortColumn): 'none' | 'ascending' | 'descending' => (
|
||||||
|
column === sortColumn ? (sortDirection === 'asc' ? 'ascending' : 'descending') : 'none'
|
||||||
|
)
|
||||||
|
|
||||||
|
const toggleGroup = (groupKey: string) => {
|
||||||
|
setCollapsedGroups((current) => {
|
||||||
|
const next = new Set(current)
|
||||||
|
if (next.has(groupKey)) next.delete(groupKey)
|
||||||
|
else next.add(groupKey)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSaved = async (message: string) => {
|
||||||
|
await loadData()
|
||||||
|
showToast('success', 'Pantry updated', message)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deletingItem) return
|
||||||
|
setDeleting(true)
|
||||||
|
try {
|
||||||
|
await api.deleteItem(deletingItem.id)
|
||||||
|
setItems((current) => current.filter((item) => item.id !== deletingItem.id))
|
||||||
|
showToast('success', 'Item removed', `${deletingItem.name} is no longer in your pantry.`)
|
||||||
|
setDeletingItem(null)
|
||||||
|
} catch (caught) {
|
||||||
|
showToast('error', 'Could not remove item', caught instanceof Error ? caught.message : undefined)
|
||||||
|
} finally {
|
||||||
|
setDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openNewItem = () => {
|
||||||
|
setEditingItem(null)
|
||||||
|
setItemEditorOpen(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="page page--inventory">
|
||||||
|
<header className="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>My pantry</h1>
|
||||||
|
</div>
|
||||||
|
<div className="page-header__actions">
|
||||||
|
<button className="button button--secondary inventory-scan-button inventory-scan-button--header" onClick={() => setScannerOpen(true)} aria-label="Scan barcode"><ScanBarcode size={18} /><span className="inventory-scan-button__label">Scan barcode</span></button>
|
||||||
|
<button className="button button--primary" onClick={openNewItem}><Plus size={18} />Add item</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="summary-strip" aria-label="Pantry summary">
|
||||||
|
<div className="summary-item summary-item--total"><span><PackageOpen size={19} /></span><p><strong>{items.length}</strong><small>Total items</small></p></div>
|
||||||
|
<div className="summary-divider" />
|
||||||
|
<div className="summary-item summary-item--warning"><span><Clock3 size={19} /></span><p><strong>{summary.expiring}</strong><small>Use this week</small></p></div>
|
||||||
|
<div className="summary-divider" />
|
||||||
|
<div className="summary-item summary-item--danger"><span><CircleAlert size={19} /></span><p><strong>{summary.expired}</strong><small>Expired</small></p></div>
|
||||||
|
<div className="summary-divider" />
|
||||||
|
<div className="summary-item summary-item--fresh"><span><Apple size={19} /></span><p><strong>{summary.fresh}</strong><small>Fresh & ready</small></p></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="inventory-toolbar">
|
||||||
|
<div className="search-field"><Search size={18} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search items, barcodes or locations…" aria-label="Search pantry" /></div>
|
||||||
|
<button className="inventory-filter-button" type="button" onClick={() => setFiltersOpen((open) => !open)} aria-expanded={filtersOpen} aria-controls="inventory-filters"><SlidersHorizontal size={16} />Filters</button>
|
||||||
|
<div className={`inventory-toolbar__filters${filtersOpen ? ' inventory-toolbar__filters--open' : ''}`} id="inventory-filters">
|
||||||
|
<div className="toolbar-select"><SlidersHorizontal size={16} /><select value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as StatusFilter)} aria-label="Filter by freshness"><option value="all">All freshness</option><option value="expired">Expired</option><option value="expiring">Use soon</option><option value="fresh">Fresh</option><option value="no-expiry">No expiry</option></select><ChevronDown size={15} /></div>
|
||||||
|
<div className="toolbar-select"><MapPin size={16} /><select value={locationFilter} onChange={(event) => setLocationFilter(event.target.value)} aria-label="Filter by location"><option value="all">All locations</option>{locations.map((location) => <option value={location.id} key={location.id}>{location.name}</option>)}</select><ChevronDown size={15} /></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="inventory-results-heading">
|
||||||
|
<p><strong>{filteredItems.length}</strong> {filteredItems.length === 1 ? 'item' : 'items'}{query ? ` matching “${query}”` : ''}</p>
|
||||||
|
{(statusFilter !== 'all' || locationFilter !== 'all' || query) && <button onClick={() => { setQuery(''); setStatusFilter('all'); setLocationFilter('all') }}>Clear filters</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<PageSkeleton />
|
||||||
|
) : loadError ? (
|
||||||
|
<EmptyState icon={CircleAlert} title="We couldn't reach your pantry" message={loadError} action={<button className="button button--secondary" onClick={() => void loadData(true)}>Try again</button>} />
|
||||||
|
) : filteredItems.length ? (
|
||||||
|
<div className="inventory-table-wrap">
|
||||||
|
<table className="inventory-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th aria-sort={sortState('name')}><button className="inventory-table__sort" type="button" onClick={() => setSort('name')}>Item {sortIcon('name')}</button></th>
|
||||||
|
<th aria-sort={sortState('quantity')}><button className="inventory-table__sort" type="button" onClick={() => setSort('quantity')}>Quantity {sortIcon('quantity')}</button></th>
|
||||||
|
<th aria-sort={sortState('location')}><button className="inventory-table__sort" type="button" onClick={() => setSort('location')}>Location {sortIcon('location')}</button></th>
|
||||||
|
<th aria-sort={sortState('expiry')}><button className="inventory-table__sort" type="button" onClick={() => setSort('expiry')}>Expiry {sortIcon('expiry')}</button></th>
|
||||||
|
<th aria-sort={sortState('status')}><button className="inventory-table__sort" type="button" onClick={() => setSort('status')}>Status {sortIcon('status')}</button></th>
|
||||||
|
<th><span className="sr-only">Actions</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{groupedItems.map((group) => {
|
||||||
|
const [firstItem] = group.items
|
||||||
|
if (group.items.length === 1) {
|
||||||
|
return <InventoryItemRow key={firstItem.id} item={firstItem} onEdit={() => { setEditingItem(firstItem); setItemEditorOpen(true) }} onDelete={() => setDeletingItem(firstItem)} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const image = itemImage(firstItem)
|
||||||
|
const isExpanded = !collapsedGroups.has(group.key)
|
||||||
|
return (
|
||||||
|
<Fragment key={group.key}>
|
||||||
|
<tr className="inventory-table__group-row">
|
||||||
|
<td>
|
||||||
|
<button className={`inventory-table__group-toggle${isExpanded ? '' : ' inventory-table__group-toggle--collapsed'}`} type="button" onClick={() => toggleGroup(group.key)} aria-expanded={isExpanded}>
|
||||||
|
<span className="inventory-table__image"><PackageOpen size={18} strokeWidth={1.5} />{image && <img src={image} alt="" onError={(event) => { event.currentTarget.style.display = 'none' }} />}</span>
|
||||||
|
<span><strong>{itemTitle(firstItem)}</strong><small>{group.items.length} individual entries</small></span>
|
||||||
|
<ChevronDown className="inventory-table__group-icon" size={16} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td>{groupQuantity(group.items)} {firstItem.amountType || 'items'}<small className="inventory-table__detail"> · {group.items.length} entries</small></td>
|
||||||
|
<td>{groupLocationLabel(group.items)}</td>
|
||||||
|
<td>Earliest: {formatDate(firstItem.expiryDate)}</td>
|
||||||
|
<td><span className={`status-chip status-chip--${expiryTone(firstItem.expiryDate)}`}>{expiryLabel(firstItem.expiryDate)}</span></td>
|
||||||
|
<td><span className="inventory-table__group-actions">{isExpanded ? 'Entries below' : 'Expand to manage'}</span></td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && group.items.map((item, entryIndex) => (
|
||||||
|
<InventoryItemRow key={item.id} item={item} entryNumber={entryIndex + 1} onEdit={() => { setEditingItem(item); setItemEditorOpen(true) }} onDelete={() => setDeletingItem(item)} />
|
||||||
|
))}
|
||||||
|
</Fragment>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={PackageOpen}
|
||||||
|
title={items.length ? 'No items match these filters' : 'Your pantry is ready for its first item'}
|
||||||
|
message={items.length ? 'Try clearing a filter or searching for something else.' : 'Add an item manually or scan a barcode to get started.'}
|
||||||
|
action={<button className="button button--primary" onClick={items.length ? () => { setQuery(''); setStatusFilter('all'); setLocationFilter('all') } : openNewItem}>{items.length ? 'Clear filters' : <><Plus size={18} />Add first item</>}</button>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{itemEditorOpen && <InventoryItemModal item={editingItem} locations={locations} onClose={() => setItemEditorOpen(false)} onSaved={handleSaved} />}
|
||||||
|
{scannerOpen && <BarcodeScannerModal locations={locations} onClose={() => setScannerOpen(false)} onSaved={handleSaved} />}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deletingItem)}
|
||||||
|
title="Remove this item?"
|
||||||
|
message={`${deletingItem?.name ?? 'This item'} will be permanently removed from your pantry.`}
|
||||||
|
loading={deleting}
|
||||||
|
onConfirm={() => void handleDelete()}
|
||||||
|
onClose={() => setDeletingItem(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{createPortal(
|
||||||
|
<button className="button button--secondary inventory-scan-button inventory-scan-button--floating" type="button" onClick={() => setScannerOpen(true)} aria-label="Scan barcode"><ScanBarcode size={18} /><span className="inventory-scan-button__label">Scan barcode</span></button>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
136
src/pages/LocationsPage.tsx
Normal file
136
src/pages/LocationsPage.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Archive, CircleAlert, Edit3, MapPin, Plus, Refrigerator, Snowflake, Trash2 } from 'lucide-react'
|
||||||
|
import { FormEvent, useCallback, useEffect, useState } from 'react'
|
||||||
|
import { ConfirmDialog, EmptyState, Modal, PageSkeleton, Spinner } from '../components/ui'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import type { Location } from '../types'
|
||||||
|
|
||||||
|
function locationIcon(name: string) {
|
||||||
|
const value = name.toLowerCase()
|
||||||
|
if (value.includes('fridge')) return Refrigerator
|
||||||
|
if (value.includes('freezer')) return Snowflake
|
||||||
|
if (value.includes('cupboard') || value.includes('cabinet')) return Archive
|
||||||
|
return MapPin
|
||||||
|
}
|
||||||
|
|
||||||
|
function LocationEditor({ location, onClose, onSaved }: { location?: Location | null; onClose: () => void; onSaved: (location: Location) => void }) {
|
||||||
|
const [name, setName] = useState(location?.name ?? '')
|
||||||
|
const [description, setDescription] = useState(location?.description ?? '')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const request = { name: name.trim(), description: description.trim() || null }
|
||||||
|
const saved = location ? await api.updateLocation(location.id, request) : await api.createLocation(request)
|
||||||
|
onSaved(saved)
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'This location could not be saved.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title={location ? 'Edit location' : 'Add a location'} eyebrow="Pantry organisation" size="small">
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<div className="modal__body">
|
||||||
|
<label className="field"><span>Location name</span><input value={name} onChange={(event) => setName(event.target.value)} placeholder="e.g. Kitchen cupboard" required autoFocus /></label>
|
||||||
|
<label className="field"><span>Description <small>optional</small></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} placeholder="What belongs here?" rows={4} /></label>
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer">
|
||||||
|
<button type="button" className="button button--ghost" onClick={onClose} disabled={saving}>Cancel</button>
|
||||||
|
<button className="button button--primary" disabled={saving || !name.trim()}>{saving && <Spinner />}{location ? 'Save changes' : 'Add location'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LocationsPage() {
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [locations, setLocations] = useState<Location[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [editorOpen, setEditorOpen] = useState(false)
|
||||||
|
const [editing, setEditing] = useState<Location | null>(null)
|
||||||
|
const [deleting, setDeleting] = useState<Location | null>(null)
|
||||||
|
const [deleteLoading, setDeleteLoading] = useState(false)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setLocations(await api.getLocations())
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Locations could not be loaded.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
const handleSaved = (saved: Location) => {
|
||||||
|
setLocations((current) => {
|
||||||
|
const exists = current.some((location) => location.id === saved.id)
|
||||||
|
return exists ? current.map((location) => location.id === saved.id ? saved : location) : [...current, saved]
|
||||||
|
})
|
||||||
|
showToast('success', editing ? 'Location updated' : 'Location added', `${saved.name} is ready to use.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!deleting) return
|
||||||
|
setDeleteLoading(true)
|
||||||
|
try {
|
||||||
|
await api.deleteLocation(deleting.id)
|
||||||
|
setLocations((current) => current.filter((location) => location.id !== deleting.id))
|
||||||
|
showToast('success', 'Location removed', `${deleting.name} was removed.`)
|
||||||
|
setDeleting(null)
|
||||||
|
} catch (caught) {
|
||||||
|
showToast('error', 'Could not remove location', caught instanceof Error ? caught.message : undefined)
|
||||||
|
} finally {
|
||||||
|
setDeleteLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header">
|
||||||
|
<div><p className="eyebrow">A place for everything</p><h1>Locations</h1><p>Organise items by fridge, freezer, cupboards or anywhere else.</p></div>
|
||||||
|
<button className="button button--primary" onClick={() => { setEditing(null); setEditorOpen(true) }}><Plus size={18} />Add location</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading ? <PageSkeleton cards={4} /> : error ? (
|
||||||
|
<EmptyState icon={CircleAlert} title="Locations are unavailable" message={error} action={<button className="button button--secondary" onClick={() => void load()}>Try again</button>} />
|
||||||
|
) : locations.length ? (
|
||||||
|
<section className="location-grid">
|
||||||
|
{locations.map((location) => {
|
||||||
|
const Icon = locationIcon(location.name)
|
||||||
|
return (
|
||||||
|
<article className="location-card" key={location.id}>
|
||||||
|
<div className="location-card__icon"><Icon size={28} strokeWidth={1.65} /></div>
|
||||||
|
<div className="location-card__copy"><p className="eyebrow">Storage area</p><h2>{location.name}</h2><p>{location.description || 'A handy place for your pantry items.'}</p></div>
|
||||||
|
<div className="location-card__actions">
|
||||||
|
<button className="button button--soft button--small" onClick={() => { setEditing(location); setEditorOpen(true) }}><Edit3 size={15} />Edit</button>
|
||||||
|
<button className="icon-button icon-button--danger" onClick={() => setDeleting(location)} aria-label={`Delete ${location.name}`}><Trash2 size={17} /></button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
<button className="location-card location-card--add" onClick={() => { setEditing(null); setEditorOpen(true) }}><span><Plus size={22} /></span><strong>Add another location</strong><small>Create a home for your items</small></button>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<EmptyState icon={MapPin} title="Create your first location" message="Locations make it easy to find every item in your kitchen." action={<button className="button button--primary" onClick={() => setEditorOpen(true)}><Plus size={18} />Add location</button>} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editorOpen && <LocationEditor location={editing} onClose={() => setEditorOpen(false)} onSaved={handleSaved} />}
|
||||||
|
<ConfirmDialog open={Boolean(deleting)} title="Delete this location?" message={`Items stored in ${deleting?.name ?? 'this location'} may need to be moved first.`} loading={deleteLoading} onConfirm={() => void handleDelete()} onClose={() => setDeleting(null)} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
92
src/pages/ProfilePage.tsx
Normal file
92
src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
import { AtSign, KeyRound, Save, ShieldCheck, UserRound } from 'lucide-react'
|
||||||
|
import { FormEvent, useState } from 'react'
|
||||||
|
import { Spinner } from '../components/ui'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { displayName, getInitials } from '../lib/format'
|
||||||
|
|
||||||
|
export function ProfilePage() {
|
||||||
|
const { user, refreshProfile } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [firstName, setFirstName] = useState(user?.firstName ?? '')
|
||||||
|
const [lastName, setLastName] = useState(user?.lastName ?? '')
|
||||||
|
const [email, setEmail] = useState(user?.email ?? '')
|
||||||
|
const [currentPassword, setCurrentPassword] = useState('')
|
||||||
|
const [newPassword, setNewPassword] = useState('')
|
||||||
|
const [savingProfile, setSavingProfile] = useState(false)
|
||||||
|
const [savingPassword, setSavingPassword] = useState(false)
|
||||||
|
const [profileError, setProfileError] = useState('')
|
||||||
|
const [passwordError, setPasswordError] = useState('')
|
||||||
|
|
||||||
|
const handleProfile = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSavingProfile(true)
|
||||||
|
setProfileError('')
|
||||||
|
try {
|
||||||
|
await api.updateProfile({ email: email.trim(), firstName: firstName.trim(), lastName: lastName.trim() })
|
||||||
|
await refreshProfile()
|
||||||
|
showToast('success', 'Profile saved', 'Your account details are up to date.')
|
||||||
|
} catch (caught) {
|
||||||
|
setProfileError(caught instanceof Error ? caught.message : 'Your profile could not be saved.')
|
||||||
|
} finally {
|
||||||
|
setSavingProfile(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePassword = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSavingPassword(true)
|
||||||
|
setPasswordError('')
|
||||||
|
try {
|
||||||
|
await api.updateProfile({ currentPassword, newPassword })
|
||||||
|
setCurrentPassword('')
|
||||||
|
setNewPassword('')
|
||||||
|
showToast('success', 'Password updated', 'Use your new password the next time you sign in.')
|
||||||
|
} catch (caught) {
|
||||||
|
setPasswordError(caught instanceof Error ? caught.message : 'Your password could not be changed.')
|
||||||
|
} finally {
|
||||||
|
setSavingPassword(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAdmin = user?.roles.some((role) => role === 'Site Admin' || role === 'Admin')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page page--profile">
|
||||||
|
<header className="page-header"><div><p className="eyebrow">Your Keeply account</p><h1>Profile & settings</h1><p>Keep your personal details and sign-in information current.</p></div></header>
|
||||||
|
|
||||||
|
<div className="profile-layout">
|
||||||
|
<aside className="profile-summary-card">
|
||||||
|
<span className="avatar avatar--profile">{getInitials(user?.firstName, user?.lastName, user?.email)}</span>
|
||||||
|
<h2>{displayName(user?.firstName, user?.lastName, user?.email)}</h2>
|
||||||
|
<p>{user?.email}</p>
|
||||||
|
<span className={`role-label ${isAdmin ? 'role-label--admin' : ''}`}>{isAdmin ? <ShieldCheck size={14} /> : <UserRound size={14} />}{isAdmin ? 'Site administrator' : 'Pantry member'}</span>
|
||||||
|
<div className="profile-summary-card__meta"><span>Account ID</span><code>{user?.id}</code></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="settings-stack">
|
||||||
|
<section className="settings-card">
|
||||||
|
<header><span><UserRound size={20} /></span><div><h2>Personal details</h2><p>Used across your household and pantry.</p></div></header>
|
||||||
|
<form onSubmit={(event) => void handleProfile(event)}>
|
||||||
|
<div className="settings-card__body">
|
||||||
|
<div className="form-row"><label className="field"><span>First name</span><input value={firstName} onChange={(event) => setFirstName(event.target.value)} required /></label><label className="field"><span>Last name</span><input value={lastName} onChange={(event) => setLastName(event.target.value)} required /></label></div>
|
||||||
|
<label className="field"><span>Email address</span><div className="input-with-icon"><AtSign size={17} /><input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required /></div></label>
|
||||||
|
{profileError && <div className="form-error" role="alert">{profileError}</div>}
|
||||||
|
</div>
|
||||||
|
<footer><button className="button button--primary" disabled={savingProfile}>{savingProfile ? <Spinner /> : <Save size={17} />}Save details</button></footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="settings-card">
|
||||||
|
<header><span><KeyRound size={20} /></span><div><h2>Change password</h2><p>Choose at least 8 characters with upper, lower and a number.</p></div></header>
|
||||||
|
<form onSubmit={(event) => void handlePassword(event)}>
|
||||||
|
<div className="settings-card__body"><label className="field"><span>Current password</span><input type="password" value={currentPassword} onChange={(event) => setCurrentPassword(event.target.value)} autoComplete="current-password" required /></label><label className="field"><span>New password</span><input type="password" value={newPassword} onChange={(event) => setNewPassword(event.target.value)} autoComplete="new-password" minLength={8} required /></label>{passwordError && <div className="form-error" role="alert">{passwordError}</div>}</div>
|
||||||
|
<footer><button className="button button--secondary" disabled={savingPassword}>{savingPassword && <Spinner />}Update password</button></footer>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
131
src/pages/UsersPage.tsx
Normal file
131
src/pages/UsersPage.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import { CircleAlert, Edit3, Search, Shield, ShieldCheck, UserCog, UsersRound } from 'lucide-react'
|
||||||
|
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import { EmptyState, Modal, PageSkeleton, Spinner } from '../components/ui'
|
||||||
|
import { useAuth } from '../context/AuthContext'
|
||||||
|
import { useToast } from '../context/ToastContext'
|
||||||
|
import { api } from '../lib/api'
|
||||||
|
import { displayName, getInitials } from '../lib/format'
|
||||||
|
import type { User } from '../types'
|
||||||
|
|
||||||
|
function UserEditor({ person, isCurrentUser, onClose, onSaved }: { person: User; isCurrentUser: boolean; onClose: () => void; onSaved: (user: User) => void }) {
|
||||||
|
const [firstName, setFirstName] = useState(person.firstName ?? '')
|
||||||
|
const [lastName, setLastName] = useState(person.lastName ?? '')
|
||||||
|
const [email, setEmail] = useState(person.email)
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [siteAdmin, setSiteAdmin] = useState(person.roles.some((role) => role === 'Site Admin' || role === 'Admin'))
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
const handleSubmit = async (event: FormEvent) => {
|
||||||
|
event.preventDefault()
|
||||||
|
setSaving(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
const saved = await api.updateUser(person.id, {
|
||||||
|
email: email.trim(),
|
||||||
|
firstName: firstName.trim(),
|
||||||
|
lastName: lastName.trim(),
|
||||||
|
...(password ? { password } : {}),
|
||||||
|
roles: siteAdmin ? ['Site Admin'] : [],
|
||||||
|
})
|
||||||
|
onSaved(saved)
|
||||||
|
onClose()
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'This user could not be updated.')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open onClose={onClose} title="Manage user" eyebrow={isCurrentUser ? 'Your administrator account' : 'Site administration'}>
|
||||||
|
<form onSubmit={(event) => void handleSubmit(event)}>
|
||||||
|
<div className="modal__body">
|
||||||
|
<div className="user-editor-heading"><span className="avatar avatar--large">{getInitials(person.firstName, person.lastName, person.email)}</span><div><strong>{displayName(person.firstName, person.lastName, person.email)}</strong><small>{person.email}</small></div></div>
|
||||||
|
<div className="form-row"><label className="field"><span>First name</span><input value={firstName} onChange={(event) => setFirstName(event.target.value)} required /></label><label className="field"><span>Last name</span><input value={lastName} onChange={(event) => setLastName(event.target.value)} required /></label></div>
|
||||||
|
<label className="field"><span>Email address</span><input type="email" value={email} onChange={(event) => setEmail(event.target.value)} required /></label>
|
||||||
|
<label className="field"><span>Set a new password <small>optional</small></span><input type="password" value={password} onChange={(event) => setPassword(event.target.value)} placeholder="Leave blank to keep the current password" minLength={8} /></label>
|
||||||
|
<label className="role-toggle">
|
||||||
|
<span className="role-toggle__icon"><ShieldCheck size={20} /></span>
|
||||||
|
<span><strong>Site administrator</strong><small>Can manage every user and create households.</small></span>
|
||||||
|
<input type="checkbox" checked={siteAdmin} onChange={(event) => setSiteAdmin(event.target.checked)} />
|
||||||
|
<i />
|
||||||
|
</label>
|
||||||
|
{isCurrentUser && !siteAdmin && <div className="form-warning">Removing your own administrator role will hide this screen after your next sign-in.</div>}
|
||||||
|
{error && <div className="form-error" role="alert">{error}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="modal__footer"><button type="button" className="button button--ghost" onClick={onClose} disabled={saving}>Cancel</button><button className="button button--primary" disabled={saving}>{saving && <Spinner />}Save user</button></div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsersPage() {
|
||||||
|
const { user } = useAuth()
|
||||||
|
const { showToast } = useToast()
|
||||||
|
const [users, setUsers] = useState<User[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [query, setQuery] = useState('')
|
||||||
|
const [editing, setEditing] = useState<User | null>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError('')
|
||||||
|
try {
|
||||||
|
setUsers(await api.getUsers())
|
||||||
|
} catch (caught) {
|
||||||
|
setError(caught instanceof Error ? caught.message : 'Users could not be loaded.')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => { void load() }, [load])
|
||||||
|
|
||||||
|
const filteredUsers = useMemo(() => {
|
||||||
|
const normalized = query.trim().toLowerCase()
|
||||||
|
if (!normalized) return users
|
||||||
|
return users.filter((person) => [person.email, person.firstName, person.lastName, ...person.roles].some((value) => value?.toLowerCase().includes(normalized)))
|
||||||
|
}, [users, query])
|
||||||
|
|
||||||
|
const adminCount = users.filter((person) => person.roles.some((role) => role === 'Site Admin' || role === 'Admin')).length
|
||||||
|
|
||||||
|
const handleSaved = (saved: User) => {
|
||||||
|
setUsers((current) => current.map((person) => person.id === saved.id ? saved : person))
|
||||||
|
showToast('success', 'User updated', `${displayName(saved.firstName, saved.lastName, saved.email)} was updated.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page">
|
||||||
|
<header className="page-header"><div><p className="eyebrow">Site administration</p><h1>Users</h1><p>Manage access, account details and administrator permissions.</p></div><div className="admin-summary"><span><UsersRound size={18} />{users.length} users</span><span><Shield size={18} />{adminCount} admins</span></div></header>
|
||||||
|
|
||||||
|
<div className="user-toolbar"><div className="search-field"><Search size={18} /><input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search people or email addresses…" /></div></div>
|
||||||
|
|
||||||
|
{loading ? <PageSkeleton cards={4} /> : error ? (
|
||||||
|
<EmptyState icon={CircleAlert} title="Users are unavailable" message={error} action={<button className="button button--secondary" onClick={() => void load()}>Try again</button>} />
|
||||||
|
) : filteredUsers.length ? (
|
||||||
|
<div className="user-table-wrap">
|
||||||
|
<table className="user-table">
|
||||||
|
<thead><tr><th>Person</th><th>Role</th><th>Account ID</th><th><span className="sr-only">Actions</span></th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
{filteredUsers.map((person) => {
|
||||||
|
const isAdmin = person.roles.some((role) => role === 'Site Admin' || role === 'Admin')
|
||||||
|
return (
|
||||||
|
<tr key={person.id}>
|
||||||
|
<td><div className="table-person"><span className="avatar">{getInitials(person.firstName, person.lastName, person.email)}</span><span><strong>{displayName(person.firstName, person.lastName, person.email)}{person.id === user?.id && <small className="you-badge">You</small>}</strong><small>{person.email}</small></span></div></td>
|
||||||
|
<td><span className={`role-label ${isAdmin ? 'role-label--admin' : ''}`}>{isAdmin ? <ShieldCheck size={14} /> : <UserCog size={14} />}{isAdmin ? 'Site administrator' : 'Member'}</span></td>
|
||||||
|
<td><code>{person.id.slice(0, 8)}…</code></td>
|
||||||
|
<td><button className="button button--soft button--small" onClick={() => setEditing(person)}><Edit3 size={15} />Manage</button></td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : <EmptyState icon={UsersRound} title="No users found" message="Try a different name, email address or role." action={<button className="button button--secondary" onClick={() => setQuery('')}>Clear search</button>} />}
|
||||||
|
|
||||||
|
{editing && <UserEditor person={editing} isCurrentUser={editing.id === user?.id} onClose={() => setEditing(null)} onSaved={handleSaved} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
135
src/types.ts
Normal file
135
src/types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
success: boolean
|
||||||
|
message?: string | null
|
||||||
|
accessToken?: string | null
|
||||||
|
refreshToken?: string | null
|
||||||
|
user?: User | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Location {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BarcodeLookup {
|
||||||
|
barcode: string
|
||||||
|
title: string
|
||||||
|
size?: unknown | null
|
||||||
|
images?: unknown | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductImage {
|
||||||
|
url?: string
|
||||||
|
image_url?: string
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItem {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
expiryDate?: string | null
|
||||||
|
barcode?: string | null
|
||||||
|
useByDate?: string | null
|
||||||
|
amount?: number | null
|
||||||
|
amountType?: string | null
|
||||||
|
itemLookupId?: string | null
|
||||||
|
itemLookupTitle?: string | null
|
||||||
|
itemLookupSize?: unknown
|
||||||
|
itemLookupImages?: unknown
|
||||||
|
itemLookupIngredients?: unknown
|
||||||
|
itemLookupNutritionFacts?: unknown
|
||||||
|
locationId?: string | null
|
||||||
|
location?: Location | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InventoryItemRequest {
|
||||||
|
name?: string
|
||||||
|
expiryDate?: string
|
||||||
|
barcode?: string
|
||||||
|
useByDate?: string
|
||||||
|
amount?: number
|
||||||
|
amountType?: string
|
||||||
|
itemLookupId?: string
|
||||||
|
locationId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HouseholdMember {
|
||||||
|
userId: string
|
||||||
|
email: string
|
||||||
|
firstName?: string | null
|
||||||
|
lastName?: string | null
|
||||||
|
joinedAt: string
|
||||||
|
isHouseholdAdmin: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Household {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description?: string | null
|
||||||
|
adminUserId: string
|
||||||
|
adminEmail: string
|
||||||
|
createdAt: string
|
||||||
|
isCurrentUserHouseholdAdmin: boolean
|
||||||
|
members: HouseholdMember[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginRequest {
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterRequest extends LoginRequest {
|
||||||
|
confirmPassword: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateProfileRequest {
|
||||||
|
email?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
currentPassword?: string
|
||||||
|
newPassword?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRequest {
|
||||||
|
email?: string
|
||||||
|
firstName?: string
|
||||||
|
lastName?: string
|
||||||
|
password?: string
|
||||||
|
roles?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ToastKind = 'success' | 'error' | 'info'
|
||||||
|
|
||||||
|
export interface ToastMessage {
|
||||||
|
id: number
|
||||||
|
kind: ToastKind
|
||||||
|
title: string
|
||||||
|
message?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface BarcodeDetectorOptions {
|
||||||
|
formats?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetectedBarcode {
|
||||||
|
rawValue: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class BarcodeDetector {
|
||||||
|
constructor(options?: BarcodeDetectorOptions)
|
||||||
|
static getSupportedFormats(): Promise<string[]>
|
||||||
|
detect(source: CanvasImageSource): Promise<DetectedBarcode[]>
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_BASE_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
18
tsconfig.node.json
Normal file
18
tsconfig.node.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
15
vite.config.ts
Normal file
15
vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'https://api.pantrymanager.kitchen/',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user