Public Access
feat: full UI redesign with design system, Nielsen heuristics compliance
- Install lucide-react, framer-motion, react-hot-toast, clsx, tailwind-merge - Custom Tailwind config: semantic color tokens, Inter font, shadow scale, border radius scale, custom animations (fadeIn, slideUp, shimmer) - Shared component library: Button, Badge, Card, Input, Select, Textarea, EmptyState, Skeleton, LoadingSpinner - Global CSS with @layer components (.btn, .card, .input, .badge, .skeleton) - Toast notification system via react-hot-toast + showToast utility - ErrorBoundary wrapper for graceful error recovery - Redesigned navigation: sticky, active state indicators, Lucide icons - Dashboard: hero header, today highlighting, scrollable week grid, redesigned meal cards, empty states, skeleton loading - Meal Detail: hero image with gradient overlay, metadata row with icons, Lucide star rating, edit-existing-feedback flow - Pantry: inline add form, search/filter, visual quantity badges, expiry warnings, confirmation dialogs - Shopping List: gradient summary cards, aisle grouping with badges, sale strikethrough pricing, empty state - Login: centered card with icon, Input component, Button component - All old gray/blue utility classes migrated to new surface/primary tokens - TypeScript clean, production build passes
This commit is contained in:
@@ -0,0 +1,98 @@
|
|||||||
|
# MealPlanner UI Redesign Plan
|
||||||
|
## Nielsen's 10 Heuristics → Concrete Changes
|
||||||
|
|
||||||
|
### Classification: `large/high-risk` — full visual redesign + component library
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1: Design System Foundation
|
||||||
|
- [ ] Custom Tailwind config: color tokens (primary, semantic, surface), font scale, spacing scale, shadow scale, radius scale
|
||||||
|
- [ ] Install icon library (Lucide React — lightweight, tree-shakeable)
|
||||||
|
- [ ] Create shared components: Button, Badge, Card, Input, Select, Textarea, Toast, Skeleton, EmptyState, Modal, Tooltip
|
||||||
|
- [ ] Define animation tokens (Framer Motion — micro-interactions, page transitions)
|
||||||
|
- [ ] Dark mode foundation (class-based, not implementing yet)
|
||||||
|
|
||||||
|
## Phase 2: Global UX Improvements (Heuristics 1, 3, 5, 6, 7, 9)
|
||||||
|
- [ ] Toast notification system (React Hot Toast) — system status visibility
|
||||||
|
- [ ] Loading states: skeleton screens for every async view
|
||||||
|
- [ ] Error boundaries + graceful error recovery UI
|
||||||
|
- [ ] Empty states for all lists (no meals, no pantry items, no shopping list)
|
||||||
|
- [ ] Confirmation dialogs for destructive actions (delete, remove from pantry, never-suggest)
|
||||||
|
- [ ] Undo capability where possible (remove from pantry → "Undo" toast)
|
||||||
|
- [ ] Keyboard shortcuts reference (Cmd+K command palette? Maybe too ambitious. Start with basic: / for search, Esc to close modals)
|
||||||
|
|
||||||
|
## Phase 3: Dashboard Redesign (Heuristic 1, 2, 6, 8)
|
||||||
|
- [ ] Visual hierarchy: week header with clear date range, status summary chips
|
||||||
|
- [ ] Day columns with clear headers (Mon 5/12, Tue 5/13, etc.)
|
||||||
|
- [ ] Meal cards: better image handling (aspect ratio, fallback), clearer metadata, hover interactions
|
||||||
|
- [ ] Quick actions bar: "Generate Week", "Send Vote Email", "View Shopping List"
|
||||||
|
- [ ] Recent activity / system status panel (last scrape time, next vote date)
|
||||||
|
- [ ] Responsive: mobile-first card stack, desktop grid
|
||||||
|
|
||||||
|
## Phase 4: Meal Detail Redesign (Heuristic 2, 5, 8)
|
||||||
|
- [ ] Hero section: full-bleed image, recipe title, key metadata row (time, servings, cost, cuisine tags)
|
||||||
|
- [ ] Tabbed interface: Overview / Ingredients / Instructions / Feedback
|
||||||
|
- [ ] Ingredients: checkbox toggle "in pantry", visual quantity display, aisle grouping
|
||||||
|
- [ ] Instructions: numbered with visual separators, progress indicator
|
||||||
|
- [ ] Feedback: inline star rating (not a separate card), compact form, saved state indicator
|
||||||
|
- [ ] Print-friendly layout preserved
|
||||||
|
|
||||||
|
## Phase 5: Pantry Redesign (Heuristic 1, 5, 6, 8)
|
||||||
|
- [ ] Inline add (not expandable form — always visible, compact)
|
||||||
|
- [ ] Search + filter existing items
|
||||||
|
- [ ] Visual quantity badges, expiry warnings (color-coded)
|
||||||
|
- [ ] Bulk actions (select multiple → remove)
|
||||||
|
- [ ] Empty state with illustration + CTA
|
||||||
|
|
||||||
|
## Phase 6: Shopping List Redesign (Heuristic 1, 2, 6, 8)
|
||||||
|
- [ ] Summary header with total cost, savings, item count
|
||||||
|
- [ ] Aisle sections as collapsible accordions
|
||||||
|
- [ ] Checkbox strikethrough animation, "in cart" vs "in pantry" distinction
|
||||||
|
- [ ] Print layout: clean, no UI chrome
|
||||||
|
- [ ] Empty state when no items
|
||||||
|
|
||||||
|
## Phase 7: Login + Global Nav (Heuristic 1, 4, 8)
|
||||||
|
- [ ] Centered card with better spacing, icon, and visual focus
|
||||||
|
- [ ] Nav bar: active state indicator, mobile hamburger, user avatar dropdown
|
||||||
|
- [ ] Breadcrumbs on sub-pages
|
||||||
|
- [ ] Footer with version + system status
|
||||||
|
|
||||||
|
## Phase 8: Accessibility + Polish (Heuristic 9, 10)
|
||||||
|
- [ ] Focus rings on all interactive elements
|
||||||
|
- [ ] ARIA labels, roles, live regions for toasts
|
||||||
|
- [ ] Color contrast audit (WCAG AA)
|
||||||
|
- [ ] Reduced motion support
|
||||||
|
- [ ] Help tooltips on complex features (vote system, matching algorithm)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Order
|
||||||
|
1. Install deps (lucide-react, framer-motion, react-hot-toast)
|
||||||
|
2. Tailwind config + base design tokens
|
||||||
|
3. Shared component library
|
||||||
|
4. Toast + loading infrastructure
|
||||||
|
5. Dashboard (highest traffic page)
|
||||||
|
6. Meal Detail
|
||||||
|
7. Pantry + Shopping List (parallel)
|
||||||
|
8. Login + Nav
|
||||||
|
9. Accessibility polish + error boundaries
|
||||||
|
10. Test responsive on mobile widths
|
||||||
|
|
||||||
|
## New Dependencies
|
||||||
|
```
|
||||||
|
npm install lucide-react framer-motion react-hot-toast
|
||||||
|
```
|
||||||
|
|
||||||
|
## Nielsen Mapping Summary
|
||||||
|
| Heuristic | Where addressed |
|
||||||
|
|---|---|
|
||||||
|
| 1. Visibility of status | Skeletons, toasts, loading spinners, progress indicators |
|
||||||
|
| 2. Match real world | Clear labels, familiar icons, calendar metaphor |
|
||||||
|
| 3. User control | Confirm dialogs, undo toasts, Escape to close |
|
||||||
|
| 4. Consistency | Design tokens, shared components, uniform spacing |
|
||||||
|
| 5. Error prevention | Form validation, disable buttons while loading, confirm destructive actions |
|
||||||
|
| 6. Recognition > recall | Visual badges, status chips, pantry checkboxes, recent activity |
|
||||||
|
| 7. Flexibility | Keyboard shortcuts, bulk actions, print layouts |
|
||||||
|
| 8. Aesthetic + minimal | Clean cards, whitespace, single primary action per view |
|
||||||
|
| 9. Error recovery | Error boundaries, friendly error messages, retry buttons |
|
||||||
|
| 10. Help | Empty state CTAs, tooltip hints, inline help text |
|
||||||
@@ -4,6 +4,8 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>MealPlanner</title>
|
<title>MealPlanner</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+108
-2
@@ -10,9 +10,14 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"@tanstack/react-query": "^5.17.0",
|
||||||
"axios": "^1.6.5",
|
"axios": "^1.6.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
"react-router-dom": "^6.21.1"
|
"react-hot-toast": "^2.6.0",
|
||||||
|
"react-router-dom": "^6.21.1",
|
||||||
|
"tailwind-merge": "^3.6.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
@@ -1756,6 +1761,15 @@
|
|||||||
"node": ">= 6"
|
"node": ">= 6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -1844,7 +1858,6 @@
|
|||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
@@ -2363,6 +2376,33 @@
|
|||||||
"url": "https://github.com/sponsors/rawify"
|
"url": "https://github.com/sponsors/rawify"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/framer-motion": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-dom": "^12.38.0",
|
||||||
|
"motion-utils": "^12.36.0",
|
||||||
|
"tslib": "^2.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@emotion/is-prop-valid": "*",
|
||||||
|
"react": "^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^18.0.0 || ^19.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@emotion/is-prop-valid": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fs.realpath": {
|
"node_modules/fs.realpath": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||||
@@ -2492,6 +2532,15 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/goober": {
|
||||||
|
"version": "2.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/goober/-/goober-2.1.19.tgz",
|
||||||
|
"integrity": "sha512-U7veizMqxyKlM58+Z5j2ngJBH/r9siDmxpvNxSw0PylF6WQvrASJEZrxh1hidRBJc2jqoBVSyOban5u8m+6Rxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"csstype": "^3.0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/gopd": {
|
"node_modules/gopd": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
@@ -2860,6 +2909,15 @@
|
|||||||
"yallist": "^3.0.2"
|
"yallist": "^3.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
@@ -2927,6 +2985,21 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/motion-dom": {
|
||||||
|
"version": "12.38.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz",
|
||||||
|
"integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"motion-utils": "^12.36.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/motion-utils": {
|
||||||
|
"version": "12.36.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz",
|
||||||
|
"integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
@@ -3397,6 +3470,23 @@
|
|||||||
"react": "^18.3.1"
|
"react": "^18.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-hot-toast": {
|
||||||
|
"version": "2.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.6.0.tgz",
|
||||||
|
"integrity": "sha512-bH+2EBMZ4sdyou/DPrfgIouFpcRLCJ+HoCA32UoAYHn6T3Ur5yfcDCeSr5mwldl6pFOsiocmrXMuoCJ1vV8bWg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"csstype": "^3.1.3",
|
||||||
|
"goober": "^2.1.16"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16",
|
||||||
|
"react-dom": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-refresh": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
@@ -3718,6 +3808,16 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tailwind-merge": {
|
||||||
|
"version": "3.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz",
|
||||||
|
"integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/dcastil"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@@ -3854,6 +3954,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/tslib": {
|
||||||
|
"version": "2.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
|
"license": "0BSD"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
|
|||||||
@@ -10,21 +10,26 @@
|
|||||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.5",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.38.0",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
|
"react-hot-toast": "^2.6.0",
|
||||||
"react-router-dom": "^6.21.1",
|
"react-router-dom": "^6.21.1",
|
||||||
"@tanstack/react-query": "^5.17.0",
|
"tailwind-merge": "^3.6.0"
|
||||||
"axios": "^1.6.5"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^18.2.48",
|
"@types/react": "^18.2.48",
|
||||||
"@types/react-dom": "^18.2.18",
|
"@types/react-dom": "^18.2.18",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.16",
|
||||||
|
"eslint": "^8.56.0",
|
||||||
"postcss": "^8.4.33",
|
"postcss": "^8.4.33",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vite": "^5.0.11",
|
"vite": "^5.0.11"
|
||||||
"eslint": "^8.56.0"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-45
@@ -1,5 +1,6 @@
|
|||||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom'
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||||
import Dashboard from './pages/Dashboard'
|
import Dashboard from './pages/Dashboard'
|
||||||
import MealDetail from './pages/MealDetail'
|
import MealDetail from './pages/MealDetail'
|
||||||
import Pantry from './pages/Pantry'
|
import Pantry from './pages/Pantry'
|
||||||
@@ -9,52 +10,64 @@ import { mealPlannerApi } from './api'
|
|||||||
|
|
||||||
const queryClient = new QueryClient()
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
function App() {
|
function Navigation() {
|
||||||
|
const location = useLocation()
|
||||||
|
const isActive = (path: string) => location.pathname === path
|
||||||
|
|
||||||
|
const linkClass = (path: string) =>
|
||||||
|
`inline-flex items-center px-3 py-2 text-sm font-medium rounded-lg transition-colors ${
|
||||||
|
isActive(path)
|
||||||
|
? 'text-primary-700 bg-primary-50'
|
||||||
|
: 'text-surface-600 hover:bg-surface-100'
|
||||||
|
}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<nav className="bg-white border-b border-surface-200 sticky top-0 z-50">
|
||||||
<BrowserRouter>
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div className="min-h-screen bg-gray-50">
|
<div className="flex justify-between h-14">
|
||||||
<nav className="bg-white shadow-sm">
|
<div className="flex items-center space-x-1">
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<Link to="/" className={linkClass('/')}>MealPlanner</Link>
|
||||||
<div className="flex justify-between h-16">
|
<Link to="/pantry" className={linkClass('/pantry')}>Pantry</Link>
|
||||||
<div className="flex space-x-8">
|
<Link to="/shopping-list" className={linkClass('/shopping-list')}>Shopping List</Link>
|
||||||
<Link to="/" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-900">
|
</div>
|
||||||
MealPlanner
|
<div className="flex items-center">
|
||||||
</Link>
|
<button
|
||||||
<Link to="/pantry" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
|
onClick={async () => {
|
||||||
Pantry
|
await mealPlannerApi.auth.logout().catch(() => {})
|
||||||
</Link>
|
window.location.href = '/login'
|
||||||
<Link to="/shopping-list" className="inline-flex items-center px-1 pt-1 text-sm font-medium text-gray-500 hover:text-gray-900">
|
}}
|
||||||
Shopping List
|
className="text-sm font-medium text-surface-500 hover:text-surface-900 px-3 py-2 rounded-lg hover:bg-surface-100 transition-colors"
|
||||||
</Link>
|
>
|
||||||
</div>
|
Sign out
|
||||||
<div className="flex items-center">
|
</button>
|
||||||
<button
|
</div>
|
||||||
onClick={async () => {
|
|
||||||
await mealPlannerApi.auth.logout().catch(() => {})
|
|
||||||
window.location.href = '/login'
|
|
||||||
}}
|
|
||||||
className="text-sm text-gray-500 hover:text-gray-900"
|
|
||||||
>
|
|
||||||
Sign out
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Dashboard />} />
|
|
||||||
<Route path="/meals/:id" element={<MealDetail />} />
|
|
||||||
<Route path="/pantry" element={<Pantry />} />
|
|
||||||
<Route path="/shopping-list" element={<ShoppingList />} />
|
|
||||||
<Route path="/login" element={<Login />} />
|
|
||||||
</Routes>
|
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</BrowserRouter>
|
</div>
|
||||||
</QueryClientProvider>
|
</nav>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default App
|
function App() {
|
||||||
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="min-h-screen bg-surface-50">
|
||||||
|
<Navigation />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/meals/:id" element={<MealDetail />} />
|
||||||
|
<Route path="/pantry" element={<Pantry />} />
|
||||||
|
<Route path="/shopping-list" element={<ShoppingList />} />
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
</ErrorBoundary>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||||
|
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||||
|
import { Button } from './ui/Button';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface State {
|
||||||
|
hasError: boolean;
|
||||||
|
error?: Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<Props, State> {
|
||||||
|
public state: State = {
|
||||||
|
hasError: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public static getDerivedStateFromError(error: Error): State {
|
||||||
|
return { hasError: true, error };
|
||||||
|
}
|
||||||
|
|
||||||
|
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||||
|
console.error('Uncaught error:', error, errorInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
public render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-surface-50 px-4">
|
||||||
|
<div className="max-w-md w-full text-center animate-fade-in">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-danger-50 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<AlertTriangle className="w-8 h-8 text-danger-500" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-xl font-bold text-surface-900 mb-2">Something went wrong</h1>
|
||||||
|
<p className="text-sm text-surface-500 mb-6">
|
||||||
|
We encountered an unexpected error. Try refreshing the page or going back to the dashboard.
|
||||||
|
</p>
|
||||||
|
{this.state.error && (
|
||||||
|
<div className="bg-surface-100 rounded-lg p-3 mb-6 text-left">
|
||||||
|
<code className="text-xs text-danger-600 break-all">{this.state.error.message}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3 justify-center">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
icon={<RefreshCw className="w-4 h-4" />}
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Refresh page
|
||||||
|
</Button>
|
||||||
|
<Button onClick={() => window.location.href = '/'}>
|
||||||
|
Go to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
variant?: 'primary' | 'success' | 'warning' | 'danger' | 'info' | 'neutral';
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ variant = 'neutral', children, className }: BadgeProps) {
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-primary-50 text-primary-700 border border-primary-200',
|
||||||
|
success: 'bg-success-50 text-success-700 border border-success-200',
|
||||||
|
warning: 'bg-warning-50 text-warning-700 border border-warning-200',
|
||||||
|
danger: 'bg-danger-50 text-danger-700 border border-danger-200',
|
||||||
|
info: 'bg-blue-50 text-blue-700 border border-blue-200',
|
||||||
|
neutral: 'bg-surface-100 text-surface-600 border border-surface-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('badge', variants[variant], className)}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import type { ButtonHTMLAttributes, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
loading?: boolean;
|
||||||
|
icon?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'md',
|
||||||
|
loading = false,
|
||||||
|
icon,
|
||||||
|
children,
|
||||||
|
className = '',
|
||||||
|
disabled,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base = 'btn';
|
||||||
|
const variants = {
|
||||||
|
primary: 'btn-primary',
|
||||||
|
secondary: 'btn-secondary',
|
||||||
|
ghost: 'btn-ghost',
|
||||||
|
danger: 'btn-danger',
|
||||||
|
};
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-3 py-1.5 text-xs',
|
||||||
|
md: 'px-4 py-2.5 text-sm',
|
||||||
|
lg: 'px-6 py-3 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`${base} ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{icon}
|
||||||
|
{children}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className }: CardProps) {
|
||||||
|
return <div className={cn('card', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardHeaderProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardHeader({ children, className }: CardHeaderProps) {
|
||||||
|
return <div className={cn('px-5 py-4 border-b border-surface-200', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardBodyProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardBody({ children, className }: CardBodyProps) {
|
||||||
|
return <div className={cn('p-5', className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardFooterProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CardFooter({ children, className }: CardFooterProps) {
|
||||||
|
return <div className={cn('px-5 py-4 border-t border-surface-200 bg-surface-50', className)}>{children}</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center py-16 px-4 text-center animate-fade-in">
|
||||||
|
<div className="w-16 h-16 rounded-2xl bg-surface-100 flex items-center justify-center mb-4">
|
||||||
|
<Icon className="w-8 h-8 text-surface-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-surface-900 mb-1">{title}</h3>
|
||||||
|
<p className="text-sm text-surface-500 max-w-sm mb-6">{description}</p>
|
||||||
|
{action && (
|
||||||
|
<Button onClick={action.onClick}>
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { forwardRef, type InputHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ label, error, hint, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && <label className="label">{label}</label>}
|
||||||
|
<input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'input',
|
||||||
|
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
|
||||||
|
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Input.displayName = 'Input';
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface LoadingSpinnerProps {
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
className?: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoadingSpinner({ size = 'md', className, label }: LoadingSpinnerProps) {
|
||||||
|
const sizes = {
|
||||||
|
sm: 'w-4 h-4',
|
||||||
|
md: 'w-6 h-6',
|
||||||
|
lg: 'w-8 h-8',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-2', className)}>
|
||||||
|
<Loader2 className={cn('animate-spin text-primary-600', sizes[size])} />
|
||||||
|
{label && <span className="text-sm text-surface-500">{label}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
import type { SelectHTMLAttributes } from 'react';
|
||||||
|
import { forwardRef } from 'react';
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
options: Array<{ value: string; label: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||||
|
({ label, error, hint, options, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && <label className="label">{label}</label>}
|
||||||
|
<select
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'input appearance-none bg-[url("data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%27http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%27%20viewBox%3D%270%200%2020%2020%27%20fill%3D%27%236b7280%27%3E%3Cpath%20fill-rule%3D%27evenodd%27%20d%3D%27M5.293%207.293a1%201%200%20011.414%200L10%2010.586l3.293-3.293a1%201%200%20111.414%201.414l-4%204a1%201%200%2001-1.414%200l-4-4a1%201%200%20010-1.414z%27%20clip-rule%3D%27evenodd%27%2F%3E%3C%2Fsvg%3E")] bg-[length:1.5em_1.5em] bg-[right_0.5rem_center] bg-no-repeat pr-10',
|
||||||
|
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
|
||||||
|
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Select.displayName = 'Select';
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
interface SkeletonProps {
|
||||||
|
className?: string;
|
||||||
|
count?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ className = 'h-4 w-full', count = 1 }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<div key={i} className={`skeleton ${className}`} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<div className="card p-5 space-y-4 animate-fade-in">
|
||||||
|
<Skeleton className="h-6 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
<Skeleton className="h-8 w-20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkeletonText({ lines = 3 }: { lines?: number }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: lines }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className={`h-4 ${i === lines - 1 ? 'w-2/3' : 'w-full'}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { forwardRef, type TextareaHTMLAttributes } from 'react';
|
||||||
|
import { cn } from '../../lib/utils';
|
||||||
|
|
||||||
|
interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ label, error, hint, className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{label && <label className="label">{label}</label>}
|
||||||
|
<textarea
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'input min-h-[100px] resize-y',
|
||||||
|
error && 'border-danger-300 focus:border-danger-500 focus:ring-danger-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
{error && <p className="mt-1 text-sm text-danger-600">{error}</p>}
|
||||||
|
{hint && !error && <p className="mt-1 text-sm text-surface-500">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
Textarea.displayName = 'Textarea';
|
||||||
+72
-5
@@ -1,10 +1,77 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap');
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
@layer base {
|
||||||
margin: 0;
|
html {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
-webkit-font-smoothing: antialiased;
|
||||||
-webkit-font-smoothing: antialiased;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #171717;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
border-color: #e5e5e5;
|
||||||
|
}
|
||||||
|
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid #0ea5e9;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::selection {
|
||||||
|
background-color: #bae6fd;
|
||||||
|
color: #0c4a6e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.btn {
|
||||||
|
@apply inline-flex items-center justify-center gap-2 px-4 py-2.5 rounded-lg font-medium text-sm transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
@apply btn bg-primary-600 text-white hover:bg-primary-700 active:bg-primary-800 shadow-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
@apply btn bg-white text-surface-700 border border-surface-300 hover:bg-surface-50 active:bg-surface-100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
@apply btn text-surface-600 hover:bg-surface-100 active:bg-surface-200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger {
|
||||||
|
@apply btn bg-danger-600 text-white hover:bg-danger-700 active:bg-danger-800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-white rounded-xl shadow-sm border border-surface-200 overflow-hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply w-full px-3 py-2.5 rounded-lg border border-surface-300 bg-white text-sm text-surface-900 placeholder:text-surface-400 transition-all duration-200 focus:border-primary-500 focus:ring-2 focus:ring-primary-100 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
@apply block text-sm font-medium text-surface-700 mb-1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
@apply inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton {
|
||||||
|
@apply animate-shimmer bg-gradient-to-r from-surface-200 via-surface-300 to-surface-200 bg-[length:200%_100%] rounded-md;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,40 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
import App from './App'
|
import App from './App'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<App />
|
||||||
|
<Toaster
|
||||||
|
position="top-right"
|
||||||
|
toastOptions={{
|
||||||
|
duration: 4000,
|
||||||
|
style: {
|
||||||
|
background: '#ffffff',
|
||||||
|
color: '#171717',
|
||||||
|
border: '1px solid #e5e5e5',
|
||||||
|
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '12px 16px',
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#16a34a',
|
||||||
|
secondary: '#f0fdf4',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
iconTheme: {
|
||||||
|
primary: '#dc2626',
|
||||||
|
secondary: '#fef2f2',
|
||||||
|
},
|
||||||
|
duration: 5000,
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,63 +1,129 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { CookingPot, CalendarDays, ShoppingCart, ChevronRight, Sparkles } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { MealPlan, MealPlanItem } from '../types'
|
import type { MealPlan, MealPlanItem } from '../types'
|
||||||
|
import { Badge } from '../components/ui/Badge'
|
||||||
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||||
|
import { SkeletonCard, Skeleton } from '../components/ui/Skeleton'
|
||||||
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
|
||||||
const DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||||
|
const FULL_DAY_NAMES = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']
|
||||||
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
const MEAL_TYPES = ['breakfast', 'lunch', 'dinner'] as const
|
||||||
|
|
||||||
function MealCard({ item }: { item: MealPlanItem }) {
|
function MealCard({ item }: { item: MealPlanItem }) {
|
||||||
|
const totalTime = item.recipe?.total_time_minutes ??
|
||||||
|
(item.recipe?.prep_time_minutes || 0) + (item.recipe?.cook_time_minutes || 0)
|
||||||
|
|
||||||
|
const statusVariant =
|
||||||
|
item.approval_status === 'approved' ? 'success' :
|
||||||
|
item.approval_status === 'denied' ? 'danger' :
|
||||||
|
item.approval_status === 'swapped' ? 'warning' :
|
||||||
|
'neutral'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-white rounded-lg shadow p-4 flex gap-4">
|
<Link
|
||||||
{item.recipe?.image_url && (
|
to={`/meals/${item.id}`}
|
||||||
<img
|
className="group block bg-surface-0 rounded-xl border border-surface-200 overflow-hidden hover:shadow-md hover:border-primary-200 transition-all duration-200"
|
||||||
src={item.recipe.image_url}
|
>
|
||||||
alt={item.recipe.name}
|
<div className="flex gap-3 p-3">
|
||||||
className="w-24 h-24 object-cover rounded-lg"
|
{item.recipe?.image_url ? (
|
||||||
/>
|
<img
|
||||||
)}
|
src={item.recipe.image_url}
|
||||||
<div className="flex-1">
|
alt={item.recipe.name}
|
||||||
<h3 className="font-semibold text-gray-900">{item.recipe?.name || 'Unknown Recipe'}</h3>
|
className="w-20 h-20 object-cover rounded-lg flex-shrink-0"
|
||||||
{item.recipe && (
|
/>
|
||||||
<p className="text-sm text-gray-500">
|
) : (
|
||||||
{item.recipe.total_time_minutes || (item.recipe.prep_time_minutes || 0) + (item.recipe.cook_time_minutes || 0)} min
|
<div className="w-20 h-20 rounded-lg bg-surface-100 flex items-center justify-center flex-shrink-0">
|
||||||
{' · '}
|
<CookingPot className="w-8 h-8 text-surface-400" />
|
||||||
{item.recipe.servings} servings
|
</div>
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
<div className="mt-2 flex items-center gap-2">
|
<div className="flex-1 min-w-0">
|
||||||
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
|
<h4 className="font-semibold text-sm text-surface-900 truncate group-hover:text-primary-700 transition-colors">
|
||||||
item.approval_status === 'approved' ? 'bg-green-100 text-green-800' :
|
{item.recipe?.name || 'Unknown Recipe'}
|
||||||
item.approval_status === 'denied' ? 'bg-red-100 text-red-800' :
|
</h4>
|
||||||
item.approval_status === 'swapped' ? 'bg-yellow-100 text-yellow-800' :
|
<p className="text-xs text-surface-500 mt-0.5">
|
||||||
'bg-gray-100 text-gray-800'
|
{totalTime > 0 && `${totalTime} min · `}
|
||||||
}`}>
|
{item.recipe?.servings} servings
|
||||||
{item.approval_status}
|
</p>
|
||||||
</span>
|
<div className="flex items-center gap-2 mt-2">
|
||||||
{item.estimated_cost && (
|
<Badge variant={statusVariant}>
|
||||||
<span className="text-sm text-gray-600">${item.estimated_cost.toFixed(2)}</span>
|
{item.approval_status}
|
||||||
)}
|
</Badge>
|
||||||
|
{item.estimated_cost && (
|
||||||
|
<span className="text-xs font-medium text-surface-600">
|
||||||
|
${item.estimated_cost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
|
||||||
|
const today = new Date().getDay()
|
||||||
|
const isToday = today === (dayIndex + 1) % 7
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-shrink-0 w-[260px]">
|
||||||
|
<div className={`px-3 py-2 rounded-lg mb-2 ${isToday ? 'bg-primary-50 border border-primary-200' : 'bg-surface-100'}`}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-sm font-semibold ${isToday ? 'text-primary-700' : 'text-surface-700'}`}>
|
||||||
|
{FULL_DAY_NAMES[dayIndex]}
|
||||||
|
</span>
|
||||||
|
{isToday && <Badge variant="primary">Today</Badge>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-surface-500">{DAY_NAMES[dayIndex]}</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{MEAL_TYPES.map(mealType => {
|
||||||
|
const item = items.find(i => i.meal_type === mealType)
|
||||||
|
return (
|
||||||
|
<div key={mealType}>
|
||||||
|
<span className="text-[10px] font-medium text-surface-400 uppercase tracking-wider px-1">
|
||||||
|
{mealType}
|
||||||
|
</span>
|
||||||
|
{item ? (
|
||||||
|
<MealCard item={item} />
|
||||||
|
) : (
|
||||||
|
<div className="h-20 rounded-xl border-2 border-dashed border-surface-200 flex items-center justify-center">
|
||||||
|
<span className="text-xs text-surface-400">—</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DayColumn({ dayIndex, items }: { dayIndex: number; items: MealPlanItem[] }) {
|
function DashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-w-[200px]">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="bg-gray-100 rounded-t-lg px-4 py-2 font-semibold text-gray-700">
|
<div className="flex justify-between items-center">
|
||||||
{DAY_NAMES[dayIndex]}
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Skeleton className="h-7 w-24" />
|
||||||
|
<Skeleton className="h-7 w-20" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 p-2">
|
<div className="flex gap-4 overflow-x-auto pb-4">
|
||||||
{MEAL_TYPES.map(mealType => {
|
{Array.from({ length: 7 }).map((_, i) => (
|
||||||
const item = items.find(i => i.meal_type === mealType)
|
<div key={i} className="flex-shrink-0 w-[260px] space-y-2">
|
||||||
return (
|
<Skeleton className="h-10 w-full" />
|
||||||
<div key={mealType} className="text-xs text-gray-500 uppercase">
|
<SkeletonCard />
|
||||||
{mealType}
|
<SkeletonCard />
|
||||||
{item ? <MealCard item={item} /> : <div className="bg-gray-50 border-2 border-dashed rounded-lg h-24" />}
|
<SkeletonCard />
|
||||||
</div>
|
</div>
|
||||||
)
|
))}
|
||||||
})}
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -70,23 +136,22 @@ export default function Dashboard() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <DashboardSkeleton />
|
||||||
<div className="flex justify-center items-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mealPlan) {
|
if (!mealPlan) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">This Week's Meal Plan</h1>
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
<CalendarDays className="w-6 h-6 text-primary-600" />
|
||||||
<p className="text-gray-500 mb-4">No meal plan generated yet</p>
|
<h1 className="text-2xl font-bold text-surface-900">This Week's Meal Plan</h1>
|
||||||
<button className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700">
|
|
||||||
Generate Meal Plan
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={Sparkles}
|
||||||
|
title="No meal plan yet"
|
||||||
|
description="Generate your first weekly meal plan to get started with smart shopping lists and vote emails."
|
||||||
|
action={{ label: 'Generate Meal Plan', onClick: () => {} }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -95,59 +160,106 @@ export default function Dashboard() {
|
|||||||
mealPlan.items.filter(item => item.day_of_week === i + 1)
|
mealPlan.items.filter(item => item.day_of_week === i + 1)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const statusVariant =
|
||||||
|
mealPlan.status === 'approved' ? 'success' :
|
||||||
|
mealPlan.status === 'locked' ? 'primary' :
|
||||||
|
mealPlan.status === 'pending_approval' ? 'warning' :
|
||||||
|
'neutral'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||||
Week of {new Date(mealPlan.week_start_date).toLocaleDateString()}
|
<div className="flex items-center gap-3">
|
||||||
</h1>
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||||
<div className="flex gap-2">
|
<CalendarDays className="w-5 h-5 text-primary-600" />
|
||||||
<span className={`inline-flex px-3 py-1 text-sm rounded-full ${
|
</div>
|
||||||
mealPlan.status === 'approved' ? 'bg-green-100 text-green-800' :
|
<div>
|
||||||
mealPlan.status === 'locked' ? 'bg-blue-100 text-blue-800' :
|
<h1 className="text-2xl font-bold text-surface-900">
|
||||||
mealPlan.status === 'pending_approval' ? 'bg-yellow-100 text-yellow-800' :
|
Week of {new Date(mealPlan.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
'bg-gray-100 text-gray-800'
|
</h1>
|
||||||
}`}>
|
<p className="text-sm text-surface-500">
|
||||||
{mealPlan.status.replace('_', ' ')}
|
{mealPlan.items.length} meals planned · {mealPlan.items.filter(i => i.approval_status === 'approved').length} approved
|
||||||
</span>
|
</p>
|
||||||
{mealPlan.total_estimated_cost && (
|
</div>
|
||||||
<span className="px-3 py-1 bg-gray-100 text-gray-800 rounded-full">
|
</div>
|
||||||
${mealPlan.total_estimated_cost.toFixed(2)} total
|
<div className="flex items-center gap-3">
|
||||||
</span>
|
<Badge variant={statusVariant}>
|
||||||
|
{mealPlan.status.replace(/_/g, ' ')}
|
||||||
|
</Badge>
|
||||||
|
{mealPlan.total_estimated_cost !== undefined && (
|
||||||
|
<div className="flex items-center gap-1.5 px-3 py-1.5 bg-surface-100 rounded-lg">
|
||||||
|
<span className="text-sm font-semibold text-surface-900">
|
||||||
|
${mealPlan.total_estimated_cost.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-surface-500">total</span>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
{/* Weekly Grid */}
|
||||||
<h2 className="text-lg font-semibold mb-4">Weekly Overview</h2>
|
<Card>
|
||||||
<div className="overflow-x-auto">
|
<CardHeader>
|
||||||
<div className="flex gap-4 min-w-[1400px]">
|
<h2 className="text-lg font-semibold text-surface-900">Weekly Overview</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex gap-4 overflow-x-auto pb-4 scrollbar-hide">
|
||||||
{itemsByDay.map((items, index) => (
|
{itemsByDay.map((items, index) => (
|
||||||
<DayColumn key={index} dayIndex={index} items={items} />
|
<DayColumn key={index} dayIndex={index} items={items} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</CardBody>
|
||||||
</div>
|
</Card>
|
||||||
|
|
||||||
|
{/* Quick Actions */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
<h2 className="text-lg font-semibold mb-4">Shopping List</h2>
|
<CardBody>
|
||||||
<a href="/shopping-list" className="text-blue-600 hover:text-blue-800">
|
<div className="flex items-start gap-4">
|
||||||
View shopping list →
|
<div className="w-10 h-10 rounded-xl bg-success-50 flex items-center justify-center flex-shrink-0">
|
||||||
</a>
|
<ShoppingCart className="w-5 h-5 text-success-600" />
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="flex-1">
|
||||||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
|
<h3 className="text-lg font-semibold text-surface-900">Shopping List</h3>
|
||||||
<div className="space-y-2">
|
<p className="text-sm text-surface-500 mt-1">
|
||||||
<a href="/pantry" className="block text-blue-600 hover:text-blue-800">
|
View your aisle-organized shopping list with sale tracking.
|
||||||
Manage Pantry →
|
</p>
|
||||||
</a>
|
<Link
|
||||||
<a href="/recipes" className="block text-blue-600 hover:text-blue-800">
|
to="/shopping-list"
|
||||||
Browse Recipes →
|
className="inline-flex items-center gap-1 mt-3 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
||||||
</a>
|
>
|
||||||
</div>
|
View shopping list <ChevronRight className="w-4 h-4" />
|
||||||
</div>
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex items-start gap-4">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Sparkles className="w-5 h-5 text-warning-600" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="text-lg font-semibold text-surface-900">Quick Actions</h3>
|
||||||
|
<div className="flex flex-col gap-2 mt-2">
|
||||||
|
<Link
|
||||||
|
to="/pantry"
|
||||||
|
className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Manage Pantry <ChevronRight className="w-4 h-4" />
|
||||||
|
</Link>
|
||||||
|
<button className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700 transition-colors text-left">
|
||||||
|
Send Vote Email <ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Lock, ArrowRight } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
|
import { Input } from '../components/ui/Input'
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
@@ -21,33 +25,41 @@ export default function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
<div className="min-h-[60vh] flex items-center justify-center">
|
||||||
<div className="bg-white rounded-lg shadow p-8 w-full max-w-sm">
|
<div className="w-full max-w-sm animate-scale-in">
|
||||||
<h1 className="text-2xl font-bold text-gray-900 mb-2 text-center">MealPlanner</h1>
|
<div className="text-center mb-8">
|
||||||
<p className="text-sm text-gray-500 text-center mb-6">Family login</p>
|
<div className="w-12 h-12 rounded-2xl bg-primary-100 flex items-center justify-center mx-auto mb-4">
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<Lock className="w-6 h-6 text-primary-600" />
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Password
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={password}
|
|
||||||
onChange={e => setPassword(e.target.value)}
|
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
autoFocus
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="text-red-600 text-sm">{error}</p>}
|
<h1 className="text-2xl font-bold text-surface-900">Welcome back</h1>
|
||||||
<button
|
<p className="text-sm text-surface-500 mt-1">Enter your family password to continue</p>
|
||||||
type="submit"
|
</div>
|
||||||
disabled={loading}
|
|
||||||
className="w-full bg-blue-600 text-white py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50 font-medium"
|
<Card>
|
||||||
>
|
<CardBody>
|
||||||
{loading ? 'Signing in…' : 'Sign in'}
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
</button>
|
<Input
|
||||||
</form>
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={e => setPassword(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
error={error || undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full"
|
||||||
|
icon={<ArrowRight className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+282
-172
@@ -1,8 +1,16 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useParams } from 'react-router-dom'
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Clock, Users, ChefHat, ArrowLeft, Printer, Star, AlertTriangle, MessageSquare } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { MealPlanItem, Feedback } from '../types'
|
import type { MealPlanItem, Feedback } from '../types'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Badge } from '../components/ui/Badge'
|
||||||
|
import { Card, CardBody, CardHeader } from '../components/ui/Card'
|
||||||
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
|
import { Select } from '../components/ui/Select'
|
||||||
|
import { Textarea } from '../components/ui/Textarea'
|
||||||
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
const DENIAL_REASONS = [
|
const DENIAL_REASONS = [
|
||||||
{ value: '', label: 'Select a reason...' },
|
{ value: '', label: 'Select a reason...' },
|
||||||
@@ -21,16 +29,57 @@ function StarRating({ value, onChange }: { value: number; onChange: (n: number)
|
|||||||
key={n}
|
key={n}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onChange(n)}
|
onClick={() => onChange(n)}
|
||||||
className={`text-2xl ${n <= value ? 'text-yellow-400' : 'text-gray-300'} hover:text-yellow-400`}
|
className={`p-0.5 transition-colors ${
|
||||||
|
n <= value ? 'text-warning-400' : 'text-surface-300'
|
||||||
|
} hover:text-warning-400 focus-visible:rounded-md`}
|
||||||
aria-label={`Rate ${n} stars`}
|
aria-label={`Rate ${n} stars`}
|
||||||
>
|
>
|
||||||
★
|
<Star className="w-7 h-7 fill-current" />
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SavedRating({ rating }: { rating: number }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-0.5">
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<Star
|
||||||
|
key={n}
|
||||||
|
className={`w-5 h-5 ${n <= rating ? 'text-warning-400 fill-current' : 'text-surface-300'}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MealDetailSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-12 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
<Skeleton className="h-5 w-24" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<SkeletonText lines={6} />
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<SkeletonText lines={8} />
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function MealDetail() {
|
export default function MealDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
@@ -52,28 +101,40 @@ export default function MealDetail() {
|
|||||||
const [reason, setReason] = useState('')
|
const [reason, setReason] = useState('')
|
||||||
const [text, setText] = useState('')
|
const [text, setText] = useState('')
|
||||||
const [submitted, setSubmitted] = useState(false)
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
const [editingFeedback, setEditingFeedback] = useState(false)
|
||||||
|
|
||||||
const submitMutation = useMutation({
|
const submitMutation = useMutation({
|
||||||
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
|
mutationFn: (payload: any) => mealPlannerApi.feedback.create(payload),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['feedback', id] })
|
queryClient.invalidateQueries({ queryKey: ['feedback', id] })
|
||||||
setSubmitted(true)
|
setSubmitted(true)
|
||||||
|
setEditingFeedback(false)
|
||||||
|
showToast.success('Feedback saved!')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast.error('Failed to save feedback. Please try again.')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <MealDetailSkeleton />
|
||||||
<div className="flex justify-center items-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item?.recipe) {
|
if (!item?.recipe) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Recipe Not Found</h1>
|
<Link to="/" className="inline-flex items-center gap-1 text-sm font-medium text-primary-600 hover:text-primary-700">
|
||||||
<p className="text-gray-500">This meal item doesn't have an associated recipe.</p>
|
<ArrowLeft className="w-4 h-4" /> Back to meal plan
|
||||||
|
</Link>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<ChefHat className="w-12 h-12 text-surface-400 mx-auto mb-4" />
|
||||||
|
<h1 className="text-xl font-bold text-surface-900">Recipe Not Found</h1>
|
||||||
|
<p className="text-sm text-surface-500 mt-2">This meal item doesn't have an associated recipe.</p>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -93,185 +154,234 @@ export default function MealDetail() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const totalTime = recipe.total_time_minutes ??
|
||||||
|
(recipe.prep_time_minutes || 0) + (recipe.cook_time_minutes || 0)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6 max-w-5xl mx-auto">
|
||||||
{recipe.image_url && (
|
{/* Back Link */}
|
||||||
<img
|
<Link to="/" className="inline-flex items-center gap-1.5 text-sm font-medium text-surface-500 hover:text-surface-900 transition-colors">
|
||||||
src={recipe.image_url}
|
<ArrowLeft className="w-4 h-4" /> Back to meal plan
|
||||||
alt={recipe.name}
|
</Link>
|
||||||
className="w-full h-64 object-cover rounded-lg"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex justify-between items-start">
|
{/* Hero */}
|
||||||
<div>
|
<div className="relative rounded-2xl overflow-hidden bg-surface-900">
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{recipe.name}</h1>
|
{recipe.image_url ? (
|
||||||
{recipe.description && (
|
<img
|
||||||
<p className="mt-2 text-gray-600">{recipe.description}</p>
|
src={recipe.image_url}
|
||||||
)}
|
alt={recipe.name}
|
||||||
</div>
|
className="w-full h-72 object-cover opacity-90"
|
||||||
<div className="text-right">
|
/>
|
||||||
<div className="text-2xl font-bold text-gray-900">
|
|
||||||
${item.estimated_cost?.toFixed(2) || 'N/A'}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500">per serving</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 text-sm text-gray-600">
|
|
||||||
{recipe.prep_time_minutes && (
|
|
||||||
<span>Prep: {recipe.prep_time_minutes} min</span>
|
|
||||||
)}
|
|
||||||
{recipe.cook_time_minutes && (
|
|
||||||
<span>Cook: {recipe.cook_time_minutes} min</span>
|
|
||||||
)}
|
|
||||||
<span>Serves: {recipe.servings}</span>
|
|
||||||
{recipe.cuisine_tags?.length > 0 && (
|
|
||||||
<span>{recipe.cuisine_tags.join(', ')}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Ingredients</h2>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{recipe.ingredients?.map((ing, idx) => (
|
|
||||||
<li key={idx} className="flex items-center gap-2">
|
|
||||||
<span className="w-2 h-2 bg-gray-400 rounded-full" />
|
|
||||||
<span>
|
|
||||||
{ing.quantity && `${ing.quantity} `}
|
|
||||||
{ing.unit && `${ing.unit} `}
|
|
||||||
{ing.name}
|
|
||||||
{ing.is_optional && ' (optional)'}
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Instructions</h2>
|
|
||||||
<ol className="space-y-4">
|
|
||||||
{recipe.instructions?.map((step, idx) => (
|
|
||||||
<li key={idx} className="flex gap-4">
|
|
||||||
<span className="flex-shrink-0 w-8 h-8 bg-blue-100 text-blue-800 rounded-full flex items-center justify-center font-semibold">
|
|
||||||
{idx + 1}
|
|
||||||
</span>
|
|
||||||
<p className="pt-1">{step}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Feedback Section */}
|
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
|
||||||
<h2 className="text-xl font-semibold mb-4">Feedback</h2>
|
|
||||||
|
|
||||||
{feedback?.rating ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-sm text-gray-600">Your rating:</span>
|
|
||||||
<div className="flex text-yellow-400">
|
|
||||||
{[1, 2, 3, 4, 5].map(n => (
|
|
||||||
<span key={n} className={n <= (feedback.rating || 0) ? 'text-yellow-400' : 'text-gray-300'}>★</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{feedback.never_suggest && (
|
|
||||||
<div className="inline-flex items-center px-3 py-1 rounded-full text-sm bg-red-100 text-red-800">
|
|
||||||
Never suggest this recipe again
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{feedback.denial_reason && (
|
|
||||||
<p className="text-sm text-gray-600 capitalize">
|
|
||||||
Reason: {feedback.denial_reason.replace('_', ' ')}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{feedback.feedback_text && (
|
|
||||||
<p className="text-sm text-gray-700 italic">"{feedback.feedback_text}"</p>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => setSubmitted(false)}
|
|
||||||
className="text-blue-600 hover:text-blue-800 text-sm"
|
|
||||||
>
|
|
||||||
Edit feedback
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : submitted ? (
|
|
||||||
<div className="text-green-700 bg-green-50 rounded-lg p-4">
|
|
||||||
Thanks for your feedback!
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="h-72 flex items-center justify-center">
|
||||||
|
<ChefHat className="w-16 h-16 text-surface-600" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
<div className="absolute bottom-0 left-0 right-0 p-6">
|
||||||
|
<div className="flex items-end justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">How was this meal?</label>
|
<h1 className="text-3xl font-bold text-white">{recipe.name}</h1>
|
||||||
<StarRating value={rating} onChange={setRating} />
|
{recipe.description && (
|
||||||
|
<p className="text-white/80 mt-1 max-w-xl text-sm">{recipe.description}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-right flex-shrink-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="text-2xl font-bold text-white">
|
||||||
<input
|
${item.estimated_cost?.toFixed(2) || 'N/A'}
|
||||||
type="checkbox"
|
</div>
|
||||||
id="neverSuggest"
|
<div className="text-sm text-white/70">per serving</div>
|
||||||
checked={neverSuggest}
|
|
||||||
onChange={(e) => setNeverSuggest(e.target.checked)}
|
|
||||||
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
|
||||||
/>
|
|
||||||
<label htmlFor="neverSuggest" className="text-sm text-gray-700">
|
|
||||||
Never suggest this recipe again
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
{/* Metadata Row */}
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Why not? (optional)</label>
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
<select
|
{totalTime > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
|
||||||
|
<Clock className="w-4 h-4 text-surface-500" />
|
||||||
|
<span>{totalTime} min total</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recipe.prep_time_minutes != null && recipe.prep_time_minutes > 0 && (
|
||||||
|
<div className="text-sm text-surface-500">
|
||||||
|
Prep {recipe.prep_time_minutes} min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recipe.cook_time_minutes != null && recipe.cook_time_minutes > 0 && (
|
||||||
|
<div className="text-sm text-surface-500">
|
||||||
|
Cook {recipe.cook_time_minutes} min
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1.5 text-sm text-surface-600 bg-surface-100 px-3 py-1.5 rounded-lg">
|
||||||
|
<Users className="w-4 h-4 text-surface-500" />
|
||||||
|
<span>{recipe.servings} servings</span>
|
||||||
|
</div>
|
||||||
|
{recipe.cuisine_tags?.length > 0 && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{recipe.cuisine_tags.map(tag => (
|
||||||
|
<Badge key={tag} variant="info">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{recipe.dietary_tags?.length > 0 && (
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{recipe.dietary_tags.map(tag => (
|
||||||
|
<Badge key={tag} variant="success">{tag}</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ingredients */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-semibold text-surface-900">Ingredients</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{recipe.ingredients?.map((ing, idx) => (
|
||||||
|
<li key={idx} className="flex items-center gap-3 py-1.5">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-primary-400 flex-shrink-0" />
|
||||||
|
<span className="text-sm text-surface-700">
|
||||||
|
<span className="font-medium">
|
||||||
|
{ing.quantity && `${ing.quantity} `}
|
||||||
|
{ing.unit && `${ing.unit} `}
|
||||||
|
</span>
|
||||||
|
{ing.name}
|
||||||
|
{ing.is_optional && (
|
||||||
|
<span className="text-surface-400 ml-1">(optional)</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Instructions */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h2 className="text-lg font-semibold text-surface-900">Instructions</h2>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{recipe.instructions?.map((step, idx) => (
|
||||||
|
<li key={idx} className="flex gap-4">
|
||||||
|
<span className="flex-shrink-0 w-8 h-8 rounded-full bg-primary-100 text-primary-700 flex items-center justify-center text-sm font-bold">
|
||||||
|
{idx + 1}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm text-surface-700 pt-1.5 leading-relaxed">{step}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Feedback */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MessageSquare className="w-5 h-5 text-primary-600" />
|
||||||
|
<h2 className="text-lg font-semibold text-surface-900">Feedback</h2>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{feedback?.rating && !editingFeedback ? (
|
||||||
|
<div className="space-y-4 animate-fade-in">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-sm font-medium text-surface-600">Your rating:</span>
|
||||||
|
<SavedRating rating={feedback.rating} />
|
||||||
|
</div>
|
||||||
|
{feedback.never_suggest && (
|
||||||
|
<Badge variant="danger">
|
||||||
|
<AlertTriangle className="w-3 h-3" /> Never suggest this recipe again
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{feedback.denial_reason && (
|
||||||
|
<p className="text-sm text-surface-600">
|
||||||
|
<span className="font-medium">Reason:</span>{' '}
|
||||||
|
{feedback.denial_reason.replace(/_/g, ' ')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{feedback.feedback_text && (
|
||||||
|
<blockquote className="text-sm text-surface-700 italic border-l-2 border-primary-200 pl-3">
|
||||||
|
"{feedback.feedback_text}"
|
||||||
|
</blockquote>
|
||||||
|
)}
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setEditingFeedback(true)}>
|
||||||
|
Edit feedback
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : submitted && !editingFeedback ? (
|
||||||
|
<div className="bg-success-50 border border-success-200 rounded-xl p-4 text-center animate-fade-in">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center mx-auto mb-2">
|
||||||
|
<Star className="w-5 h-5 text-success-600 fill-current" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium text-success-700">Thanks for your feedback!</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="label">How was this meal?</label>
|
||||||
|
<StarRating value={rating} onChange={setRating} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 p-3 bg-danger-50 rounded-xl border border-danger-100">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="neverSuggest"
|
||||||
|
checked={neverSuggest}
|
||||||
|
onChange={(e) => setNeverSuggest(e.target.checked)}
|
||||||
|
className="w-4 h-4 rounded border-danger-300 text-danger-600 focus:ring-danger-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="neverSuggest" className="text-sm text-danger-700 font-medium">
|
||||||
|
Never suggest this recipe again
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Why not? (optional)"
|
||||||
|
options={DENIAL_REASONS}
|
||||||
value={reason}
|
value={reason}
|
||||||
onChange={(e) => setReason(e.target.value)}
|
onChange={(e) => setReason(e.target.value)}
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
/>
|
||||||
>
|
|
||||||
{DENIAL_REASONS.map(r => (
|
|
||||||
<option key={r.value} value={r.value}>{r.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<Textarea
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Additional comments</label>
|
label="Additional comments"
|
||||||
<textarea
|
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => setText(e.target.value)}
|
onChange={(e) => setText(e.target.value)}
|
||||||
rows={3}
|
|
||||||
className="w-full border border-gray-300 rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
|
||||||
placeholder="Anything else you'd like to share..."
|
placeholder="Anything else you'd like to share..."
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
type="submit"
|
<Button
|
||||||
disabled={submitMutation.isPending}
|
type="submit"
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
loading={submitMutation.isPending}
|
||||||
>
|
disabled={submitMutation.isPending}
|
||||||
{submitMutation.isPending ? 'Saving...' : 'Submit Feedback'}
|
>
|
||||||
</button>
|
Submit Feedback
|
||||||
|
</Button>
|
||||||
|
{editingFeedback && (
|
||||||
|
<Button variant="ghost" onClick={() => setEditingFeedback(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{submitMutation.isError && (
|
{/* Actions */}
|
||||||
<p className="text-sm text-red-600">Failed to save feedback. Please try again.</p>
|
<div className="flex gap-3">
|
||||||
)}
|
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<button
|
|
||||||
onClick={() => window.print()}
|
|
||||||
className="bg-gray-100 text-gray-800 px-4 py-2 rounded-lg hover:bg-gray-200"
|
|
||||||
>
|
|
||||||
Print Recipe
|
Print Recipe
|
||||||
</button>
|
</Button>
|
||||||
<a
|
<Link to="/">
|
||||||
href="/"
|
<Button>Back to Meal Plan</Button>
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
|
</Link>
|
||||||
>
|
|
||||||
Back to Meal Plan
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
+200
-102
@@ -1,14 +1,23 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Plus, Search, Trash2, Package, AlertTriangle } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { HomePantryItem, Ingredient } from '../types'
|
import type { HomePantryItem, Ingredient } from '../types'
|
||||||
import { useState } from 'react'
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
|
import { Input } from '../components/ui/Input'
|
||||||
|
import { Select } from '../components/ui/Select'
|
||||||
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
import { showToast } from '../lib/toast'
|
||||||
|
|
||||||
export default function Pantry() {
|
export default function Pantry() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
const [showAddForm, setShowAddForm] = useState(false)
|
const [showAddForm, setShowAddForm] = useState(false)
|
||||||
const [selectedIngredient, setSelectedIngredient] = useState<string>('')
|
const [selectedIngredient, setSelectedIngredient] = useState('')
|
||||||
const [quantity, setQuantity] = useState<string>('')
|
const [quantity, setQuantity] = useState('')
|
||||||
const [unit, setUnit] = useState<string>('')
|
const [unit, setUnit] = useState('')
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
|
||||||
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
|
||||||
queryKey: ['pantry'],
|
queryKey: ['pantry'],
|
||||||
@@ -29,6 +38,10 @@ export default function Pantry() {
|
|||||||
setSelectedIngredient('')
|
setSelectedIngredient('')
|
||||||
setQuantity('')
|
setQuantity('')
|
||||||
setUnit('')
|
setUnit('')
|
||||||
|
showToast.success('Item added to pantry')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast.error('Failed to add item')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,6 +49,10 @@ export default function Pantry() {
|
|||||||
mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
|
mutationFn: (id: string) => mealPlannerApi.pantry.remove(id),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
queryClient.invalidateQueries({ queryKey: ['pantry'] })
|
||||||
|
showToast.success('Item removed')
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showToast.error('Failed to remove item')
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,132 +65,213 @@ export default function Pantry() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const filteredItems = pantryItems?.filter(item =>
|
||||||
|
item.ingredient?.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
|
||||||
|
const ingredientOptions = ingredients?.map(ing => ({
|
||||||
|
value: ing.id,
|
||||||
|
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
|
||||||
|
})) || []
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center py-12">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
<div className="flex justify-between items-center">
|
||||||
|
<Skeleton className="h-8 w-32" />
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<SkeletonText lines={3} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
|
<div key={i} className="flex justify-between">
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="h-5 w-20" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Home Pantry</h1>
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||||
<button
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-warning-50 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-warning-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-surface-900">Home Pantry</h1>
|
||||||
|
<p className="text-sm text-surface-500">
|
||||||
|
{pantryItems?.length || 0} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
icon={<Plus className="w-4 h-4" />}
|
||||||
onClick={() => setShowAddForm(!showAddForm)}
|
onClick={() => setShowAddForm(!showAddForm)}
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
|
variant={showAddForm ? 'secondary' : 'primary'}
|
||||||
>
|
>
|
||||||
{showAddForm ? 'Cancel' : 'Add Item'}
|
{showAddForm ? 'Cancel' : 'Add Item'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Form */}
|
||||||
{showAddForm && (
|
{showAddForm && (
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<Card className="animate-slide-down">
|
||||||
<h2 className="text-lg font-semibold mb-4">Add Pantry Item</h2>
|
<CardBody>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
|
||||||
<div>
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Ingredient</label>
|
<div className="md:col-span-2">
|
||||||
<select
|
<Select
|
||||||
value={selectedIngredient}
|
label="Ingredient"
|
||||||
onChange={(e) => setSelectedIngredient(e.target.value)}
|
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]}
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
value={selectedIngredient}
|
||||||
>
|
onChange={(e) => setSelectedIngredient(e.target.value)}
|
||||||
<option value="">Select ingredient...</option>
|
/>
|
||||||
{ingredients?.map(ing => (
|
</div>
|
||||||
<option key={ing.id} value={ing.id}>
|
<Input
|
||||||
{ing.name} {ing.aisle ? `(${ing.aisle})` : ''}
|
label="Quantity"
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Quantity</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
type="number"
|
||||||
value={quantity}
|
value={quantity}
|
||||||
onChange={(e) => setQuantity(e.target.value)}
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
placeholder="e.g., 5"
|
placeholder="e.g., 5"
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<Input
|
||||||
<div>
|
label="Unit"
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">Unit</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={unit}
|
value={unit}
|
||||||
onChange={(e) => setUnit(e.target.value)}
|
onChange={(e) => setUnit(e.target.value)}
|
||||||
placeholder="e.g., cans, lbs"
|
placeholder="cans, lbs, etc."
|
||||||
className="w-full border border-gray-300 rounded-lg px-3 py-2"
|
|
||||||
/>
|
/>
|
||||||
|
<div className="flex items-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleAdd}
|
||||||
|
loading={addMutation.isPending}
|
||||||
|
disabled={!selectedIngredient || addMutation.isPending}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
</CardBody>
|
||||||
<button
|
</Card>
|
||||||
onClick={handleAdd}
|
)}
|
||||||
disabled={!selectedIngredient || addMutation.isPending}
|
|
||||||
className="bg-blue-600 text-white px-6 py-2 rounded-lg hover:bg-blue-700 disabled:opacity-50"
|
{/* Search */}
|
||||||
>
|
{pantryItems && pantryItems.length > 0 && (
|
||||||
{addMutation.isPending ? 'Adding...' : 'Add'}
|
<div className="relative">
|
||||||
</button>
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-surface-400" />
|
||||||
</div>
|
<Input
|
||||||
</div>
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search pantry items..."
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pantryItems && pantryItems.length > 0 ? (
|
{/* Items List */}
|
||||||
<div className="bg-white rounded-lg shadow overflow-hidden">
|
{filteredItems && filteredItems.length > 0 ? (
|
||||||
<table className="min-w-full divide-y divide-gray-200">
|
<Card className="overflow-hidden">
|
||||||
<thead className="bg-gray-50">
|
<div className="overflow-x-auto">
|
||||||
<tr>
|
<table className="w-full">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Item</th>
|
<thead>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Aisle</th>
|
<tr className="border-b border-surface-200 bg-surface-50">
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Quantity</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Item</th>
|
||||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Expires</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Aisle</th>
|
||||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Actions</th>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Quantity</th>
|
||||||
</tr>
|
<th className="px-6 py-3 text-left text-xs font-semibold text-surface-500 uppercase tracking-wider">Expires</th>
|
||||||
</thead>
|
<th className="px-6 py-3 text-right text-xs font-semibold text-surface-500 uppercase tracking-wider">Actions</th>
|
||||||
<tbody className="divide-y divide-gray-200">
|
|
||||||
{pantryItems.map((item) => (
|
|
||||||
<tr key={item.id}>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap">
|
|
||||||
<div className="font-medium text-gray-900">
|
|
||||||
{item.ingredient?.name || 'Unknown'}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{item.ingredient?.aisle || '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{item.quantity} {item.unit || ''}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
|
||||||
{item.expires_at ? new Date(item.expires_at).toLocaleDateString() : '-'}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 whitespace-nowrap text-right">
|
|
||||||
<button
|
|
||||||
onClick={() => removeMutation.mutate(item.id)}
|
|
||||||
className="text-red-600 hover:text-red-800"
|
|
||||||
disabled={removeMutation.isPending}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody className="divide-y divide-surface-200">
|
||||||
</table>
|
{filteredItems.map((item) => {
|
||||||
</div>
|
const isExpiringSoon = item.expires_at &&
|
||||||
|
new Date(item.expires_at).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-surface-50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 rounded-lg bg-primary-50 flex items-center justify-center flex-shrink-0">
|
||||||
|
<Package className="w-4 h-4 text-primary-600" />
|
||||||
|
</div>
|
||||||
|
<span className="font-medium text-sm text-surface-900">
|
||||||
|
{item.ingredient?.name || 'Unknown'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-sm text-surface-500">
|
||||||
|
{item.ingredient?.aisle || <span className="text-surface-400">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-surface-100 text-surface-700">
|
||||||
|
{item.quantity} {item.unit || ''}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
{item.expires_at ? (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{isExpiringSoon && <AlertTriangle className="w-4 h-4 text-warning-500" />}
|
||||||
|
<span className={`text-sm ${isExpiringSoon ? 'text-warning-600 font-medium' : 'text-surface-500'}`}>
|
||||||
|
{new Date(item.expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-surface-400">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
icon={<Trash2 className="w-4 h-4" />}
|
||||||
|
onClick={() => {
|
||||||
|
if (confirm(`Remove ${item.ingredient?.name || 'this item'} from pantry?`)) {
|
||||||
|
removeMutation.mutate(item.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
loading={removeMutation.isPending}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
<EmptyState
|
||||||
<p className="text-gray-500 mb-4">Your pantry is empty</p>
|
icon={Package}
|
||||||
<button
|
title={searchQuery ? 'No matches found' : 'Your pantry is empty'}
|
||||||
onClick={() => setShowAddForm(true)}
|
description={
|
||||||
className="text-blue-600 hover:text-blue-800"
|
searchQuery
|
||||||
>
|
? "Try adjusting your search."
|
||||||
Add your first item
|
: "Add ingredients you already have at home to avoid buying duplicates."
|
||||||
</button>
|
}
|
||||||
</div>
|
action={
|
||||||
|
!searchQuery
|
||||||
|
? { label: 'Add your first item', onClick: () => setShowAddForm(true) }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,44 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Printer, ShoppingCart, Package, Tag, Receipt } from 'lucide-react'
|
||||||
import { mealPlannerApi } from '../api'
|
import { mealPlannerApi } from '../api'
|
||||||
import type { ShoppingList } from '../types'
|
import type { ShoppingList } from '../types'
|
||||||
|
import { Button } from '../components/ui/Button'
|
||||||
|
import { Badge } from '../components/ui/Badge'
|
||||||
|
import { Card, CardBody } from '../components/ui/Card'
|
||||||
|
import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
|
||||||
|
import { EmptyState } from '../components/ui/EmptyState'
|
||||||
|
|
||||||
|
function ShoppingListSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<Skeleton className="h-8 w-64" />
|
||||||
|
<Skeleton className="h-9 w-28" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
<SkeletonCard />
|
||||||
|
</div>
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<SkeletonText lines={6} />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SkeletonCard() {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Skeleton className="h-10 w-24 mb-2" />
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function ShoppingListPage() {
|
export default function ShoppingListPage() {
|
||||||
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
|
const { data: shoppingList, isLoading } = useQuery<ShoppingList>({
|
||||||
@@ -9,98 +47,147 @@ export default function ShoppingListPage() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <ShoppingListSkeleton />
|
||||||
<div className="flex justify-center items-center py-12">
|
|
||||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!shoppingList || shoppingList.items.length === 0) {
|
if (!shoppingList || shoppingList.items.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Shopping List</h1>
|
<div className="flex items-center gap-3">
|
||||||
<div className="bg-white rounded-lg shadow p-8 text-center">
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||||
<p className="text-gray-500 mb-4">No shopping list available</p>
|
<ShoppingCart className="w-5 h-5 text-primary-600" />
|
||||||
<p className="text-sm text-gray-400">Generate a meal plan first to see your shopping list.</p>
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-surface-900">Shopping List</h1>
|
||||||
</div>
|
</div>
|
||||||
|
<EmptyState
|
||||||
|
icon={Receipt}
|
||||||
|
title="No shopping list yet"
|
||||||
|
description="Generate a meal plan first to see your aisle-organized shopping list with sale tracking."
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex justify-between items-center">
|
{/* Header */}
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
|
||||||
Shopping List - Week of {new Date(shoppingList.week_start_date).toLocaleDateString()}
|
<div className="flex items-center gap-3">
|
||||||
</h1>
|
<div className="w-10 h-10 rounded-xl bg-primary-50 flex items-center justify-center">
|
||||||
<button
|
<ShoppingCart className="w-5 h-5 text-primary-600" />
|
||||||
onClick={() => window.print()}
|
</div>
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700"
|
<div>
|
||||||
>
|
<h1 className="text-2xl font-bold text-surface-900">
|
||||||
|
Shopping List
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-surface-500">
|
||||||
|
Week of {new Date(shoppingList.week_start_date).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" icon={<Printer className="w-4 h-4" />} onClick={() => window.print()}>
|
||||||
Print List
|
Print List
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
{/* Summary Stats */}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
<div className="text-3xl font-bold text-gray-900">
|
<Card className="bg-gradient-to-br from-primary-50 to-primary-100/50 border-primary-200">
|
||||||
${shoppingList.total_estimated_cost.toFixed(2)}
|
<CardBody>
|
||||||
</div>
|
<div className="flex items-center gap-3">
|
||||||
<div className="text-sm text-gray-500">Estimated Total</div>
|
<div className="w-10 h-10 rounded-xl bg-primary-100 flex items-center justify-center">
|
||||||
</div>
|
<Receipt className="w-5 h-5 text-primary-600" />
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
</div>
|
||||||
<div className="text-3xl font-bold text-gray-900">{shoppingList.items.length}</div>
|
<div>
|
||||||
<div className="text-sm text-gray-500">Total Items</div>
|
<div className="text-2xl font-bold text-surface-900">
|
||||||
</div>
|
${shoppingList.total_estimated_cost.toFixed(2)}
|
||||||
<div className="bg-white rounded-lg shadow p-6">
|
</div>
|
||||||
<div className="text-3xl font-bold text-green-600">{shoppingList.sale_items_count}</div>
|
<div className="text-sm text-surface-500">Estimated Total</div>
|
||||||
<div className="text-sm text-gray-500">Items on Sale</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-surface-100 flex items-center justify-center">
|
||||||
|
<Package className="w-5 h-5 text-surface-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-surface-900">{shoppingList.items.length}</div>
|
||||||
|
<div className="text-sm text-surface-500">Total Items</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="bg-gradient-to-br from-success-50 to-success-100/50 border-success-200">
|
||||||
|
<CardBody>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-xl bg-success-100 flex items-center justify-center">
|
||||||
|
<Tag className="w-5 h-5 text-success-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold text-success-700">{shoppingList.sale_items_count}</div>
|
||||||
|
<div className="text-sm text-surface-500">Items on Sale</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
|
{/* Aisle Groups */}
|
||||||
<div key={aisle} className="bg-white rounded-lg shadow">
|
<div className="space-y-4">
|
||||||
<div className="px-6 py-4 border-b border-gray-200 bg-gray-50">
|
{Object.entries(shoppingList.by_aisle).map(([aisle, items]) => (
|
||||||
<h2 className="text-lg font-semibold text-gray-900">{aisle}</h2>
|
<Card key={aisle} className="overflow-hidden">
|
||||||
<span className="text-sm text-gray-500">{items.length} items</span>
|
<div className="px-5 py-3 border-b border-surface-200 bg-surface-50 flex items-center justify-between">
|
||||||
</div>
|
<h3 className="text-sm font-semibold text-surface-900">{aisle}</h3>
|
||||||
<ul className="divide-y divide-gray-200">
|
<Badge variant="neutral">{items.length} items</Badge>
|
||||||
{items.map((item, idx) => (
|
</div>
|
||||||
<li key={idx} className="px-6 py-4 flex items-center justify-between">
|
<ul className="divide-y divide-surface-200">
|
||||||
<div className="flex items-center gap-4">
|
{items.map((item, idx) => (
|
||||||
<div className="w-5 h-5 border-2 border-gray-300 rounded flex-shrink-0" />
|
<li
|
||||||
<div>
|
key={idx}
|
||||||
<span className="font-medium text-gray-900">{item.name}</span>
|
className="px-5 py-3.5 flex items-center justify-between hover:bg-surface-50 transition-colors"
|
||||||
{item.in_pantry && (
|
>
|
||||||
<span className="ml-2 text-xs bg-green-100 text-green-800 px-2 py-0.5 rounded">
|
<div className="flex items-center gap-3">
|
||||||
In Pantry
|
<div className="w-5 h-5 rounded border-2 border-surface-300 flex-shrink-0" />
|
||||||
</span>
|
<div>
|
||||||
)}
|
<span className="text-sm font-medium text-surface-900">{item.name}</span>
|
||||||
{item.is_on_sale && (
|
<div className="flex gap-1.5 mt-0.5">
|
||||||
<span className="ml-2 text-xs bg-red-100 text-red-800 px-2 py-0.5 rounded">
|
{item.in_pantry && <Badge variant="success">In Pantry</Badge>}
|
||||||
SALE
|
{item.is_on_sale && <Badge variant="danger">SALE</Badge>}
|
||||||
</span>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="text-right flex items-center gap-3">
|
||||||
<div className="text-right">
|
{item.quantity && (
|
||||||
{item.quantity && (
|
<span className="text-sm text-surface-500">
|
||||||
<span className="text-sm text-gray-500 mr-4">
|
{item.quantity} {item.unit || ''}
|
||||||
{item.quantity} {item.unit || ''}
|
</span>
|
||||||
</span>
|
)}
|
||||||
)}
|
{item.sale_price ? (
|
||||||
{item.sale_price ? (
|
<div className="flex flex-col items-end">
|
||||||
<span className="font-semibold text-red-600">${item.sale_price.toFixed(2)}</span>
|
<span className="text-sm font-semibold text-danger-600">${item.sale_price.toFixed(2)}</span>
|
||||||
) : item.estimated_price ? (
|
{item.estimated_price && item.estimated_price > item.sale_price && (
|
||||||
<span className="text-gray-600">${item.estimated_price.toFixed(2)}</span>
|
<span className="text-xs text-surface-400 line-through">
|
||||||
) : null}
|
${item.estimated_price.toFixed(2)}
|
||||||
</div>
|
</span>
|
||||||
</li>
|
)}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
) : item.estimated_price ? (
|
||||||
</div>
|
<span className="text-sm font-medium text-surface-700">
|
||||||
))}
|
${item.estimated_price.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+139
-2
@@ -5,7 +5,144 @@ export default {
|
|||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
"./src/**/*.{js,ts,jsx,tsx}",
|
||||||
],
|
],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {},
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
100: '#e0f2fe',
|
||||||
|
200: '#bae6fd',
|
||||||
|
300: '#7dd3fc',
|
||||||
|
400: '#38bdf8',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
600: '#0284c7',
|
||||||
|
700: '#0369a1',
|
||||||
|
800: '#075985',
|
||||||
|
900: '#0c4a6e',
|
||||||
|
950: '#082f49',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
50: '#f0fdf4',
|
||||||
|
100: '#dcfce7',
|
||||||
|
200: '#bbf7d0',
|
||||||
|
300: '#86efac',
|
||||||
|
400: '#4ade80',
|
||||||
|
500: '#22c55e',
|
||||||
|
600: '#16a34a',
|
||||||
|
700: '#15803d',
|
||||||
|
800: '#166534',
|
||||||
|
900: '#14532d',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
50: '#fffbeb',
|
||||||
|
100: '#fef3c7',
|
||||||
|
200: '#fde68a',
|
||||||
|
300: '#fcd34d',
|
||||||
|
400: '#fbbf24',
|
||||||
|
500: '#f59e0b',
|
||||||
|
600: '#d97706',
|
||||||
|
700: '#b45309',
|
||||||
|
800: '#92400e',
|
||||||
|
900: '#78350f',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
50: '#fef2f2',
|
||||||
|
100: '#fee2e2',
|
||||||
|
200: '#fecaca',
|
||||||
|
300: '#fca5a5',
|
||||||
|
400: '#f87171',
|
||||||
|
500: '#ef4444',
|
||||||
|
600: '#dc2626',
|
||||||
|
700: '#b91c1c',
|
||||||
|
800: '#991b1b',
|
||||||
|
900: '#7f1d1d',
|
||||||
|
},
|
||||||
|
surface: {
|
||||||
|
0: '#ffffff',
|
||||||
|
50: '#fafafa',
|
||||||
|
100: '#f5f5f5',
|
||||||
|
200: '#e5e5e5',
|
||||||
|
300: '#d4d4d4',
|
||||||
|
400: '#a3a3a3',
|
||||||
|
500: '#737373',
|
||||||
|
600: '#525252',
|
||||||
|
700: '#404040',
|
||||||
|
800: '#262626',
|
||||||
|
900: '#171717',
|
||||||
|
950: '#0a0a0a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
sans: ['Inter', 'system-ui', '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'sans-serif'],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
'xs': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
'sm': '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
|
||||||
|
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
|
||||||
|
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
|
||||||
|
'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
|
||||||
|
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
|
||||||
|
'inner': 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
|
||||||
|
},
|
||||||
|
borderRadius: {
|
||||||
|
'xs': '2px',
|
||||||
|
'sm': '4px',
|
||||||
|
'md': '6px',
|
||||||
|
'lg': '8px',
|
||||||
|
'xl': '12px',
|
||||||
|
'2xl': '16px',
|
||||||
|
'3xl': '24px',
|
||||||
|
'full': '9999px',
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
'fade-in': 'fadeIn 0.2s ease-out',
|
||||||
|
'slide-up': 'slideUp 0.3s ease-out',
|
||||||
|
'slide-down': 'slideDown 0.3s ease-out',
|
||||||
|
'scale-in': 'scaleIn 0.2s ease-out',
|
||||||
|
'pulse-soft': 'pulseSoft 2s ease-in-out infinite',
|
||||||
|
'shimmer': 'shimmer 2s linear infinite',
|
||||||
|
},
|
||||||
|
keyframes: {
|
||||||
|
fadeIn: {
|
||||||
|
'0%': { opacity: '0' },
|
||||||
|
'100%': { opacity: '1' },
|
||||||
|
},
|
||||||
|
slideUp: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
slideDown: {
|
||||||
|
'0%': { opacity: '0', transform: 'translateY(-8px)' },
|
||||||
|
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||||
|
},
|
||||||
|
scaleIn: {
|
||||||
|
'0%': { opacity: '0', transform: 'scale(0.95)' },
|
||||||
|
'100%': { opacity: '1', transform: 'scale(1)' },
|
||||||
|
},
|
||||||
|
pulseSoft: {
|
||||||
|
'0%, 100%': { opacity: '1' },
|
||||||
|
'50%': { opacity: '0.7' },
|
||||||
|
},
|
||||||
|
shimmer: {
|
||||||
|
'0%': { backgroundPosition: '-200% 0' },
|
||||||
|
'100%': { backgroundPosition: '200% 0' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [
|
||||||
|
function({ addUtilities }) {
|
||||||
|
addUtilities({
|
||||||
|
'.text-balance': {
|
||||||
|
'text-wrap': 'balance',
|
||||||
|
},
|
||||||
|
'.scrollbar-hide': {
|
||||||
|
'-ms-overflow-style': 'none',
|
||||||
|
'scrollbar-width': 'none',
|
||||||
|
},
|
||||||
|
'.scrollbar-hide::-webkit-scrollbar': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user