forked from cartsnitch/cartsnitch
feat: add core PWA screens (auth, dashboard, purchases, products, alerts, settings)
Build all 8 primary screens for CAR-33 on top of the Phase 1 scaffold: - Auth: login, register, forgot password with JWT flow and mock fallback - Dashboard: triggered alerts banner, spending stats, price trend sparklines (Recharts), recent purchases - Purchase History: store filter chips, paginated list with item previews - Purchase Detail: receipt view with line items linking to product pages - Products: search with instant filter, store price comparison badges - Product Detail: 90-day price history chart (Recharts), store comparison table - Store Comparison: ranked store cards with savings banner - Price Alerts: triggered/watching sections, create form, progress bars, delete - Coupons: expiration warnings, copy-to-clipboard coupon codes - Account Linking: connect Meijer/Kroger/Target with status indicators - Settings: profile, connected stores, notification toggles, theme switcher, sign out Also adds: - Mock data layer (src/lib/mock-data.ts) for demo/screenshot use - StoreIcon component with store brand colors - Code-split Recharts chunk (initial JS: 117KB, Recharts lazy: 498KB) - All 48px+ touch targets, mobile-first Tailwind layout Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
+24
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#1e40af" />
|
||||||
|
<meta name="description" content="Track prices, find coupons, and optimize your grocery shopping." />
|
||||||
|
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
|
||||||
|
<title>CartSnitch</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Generated
+9958
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,43 @@
|
|||||||
|
{
|
||||||
|
"name": "cartsnitch",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "NODE_ENV=test vitest run",
|
||||||
|
"test:watch": "NODE_ENV=test vitest"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-query": "^5.0.0",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1",
|
||||||
|
"react-router-dom": "^7.0.0",
|
||||||
|
"recharts": "^3.8.0",
|
||||||
|
"zustand": "^5.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"@testing-library/jest-dom": "^6.6.3",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^18.3.28",
|
||||||
|
"@types/react-dom": "^18.3.7",
|
||||||
|
"@vitejs/plugin-react": "^4.5.2",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"jsdom": "^25.0.1",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "^5.7.3",
|
||||||
|
"typescript-eslint": "^8.56.1",
|
||||||
|
"vite": "^6.3.5",
|
||||||
|
"vite-plugin-pwa": "^0.21.2",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,17 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
describe('App', () => {
|
||||||
|
it('renders the dashboard on the root route', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.getByText('CartSnitch')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders the bottom navigation', () => {
|
||||||
|
render(<App />)
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Purchases')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Products')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
})
|
||||||
+51
@@ -0,0 +1,51 @@
|
|||||||
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { Layout } from './components/Layout.tsx'
|
||||||
|
import { Dashboard } from './pages/Dashboard.tsx'
|
||||||
|
import { Purchases } from './pages/Purchases.tsx'
|
||||||
|
import { PurchaseDetail } from './pages/PurchaseDetail.tsx'
|
||||||
|
import { Products } from './pages/Products.tsx'
|
||||||
|
import { ProductDetail } from './pages/ProductDetail.tsx'
|
||||||
|
import { StoreComparison } from './pages/StoreComparison.tsx'
|
||||||
|
import { Coupons } from './pages/Coupons.tsx'
|
||||||
|
import { Alerts } from './pages/Alerts.tsx'
|
||||||
|
import { Settings } from './pages/Settings.tsx'
|
||||||
|
import { AccountLinking } from './pages/AccountLinking.tsx'
|
||||||
|
import { Login } from './pages/Login.tsx'
|
||||||
|
import { Register } from './pages/Register.tsx'
|
||||||
|
import { ForgotPassword } from './pages/ForgotPassword.tsx'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000 * 60 * 5,
|
||||||
|
retry: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route element={<Layout />}>
|
||||||
|
<Route index element={<Dashboard />} />
|
||||||
|
<Route path="purchases" element={<Purchases />} />
|
||||||
|
<Route path="purchases/:id" element={<PurchaseDetail />} />
|
||||||
|
<Route path="products" element={<Products />} />
|
||||||
|
<Route path="products/:id" element={<ProductDetail />} />
|
||||||
|
<Route path="compare/:productId" element={<StoreComparison />} />
|
||||||
|
<Route path="coupons" element={<Coupons />} />
|
||||||
|
<Route path="alerts" element={<Alerts />} />
|
||||||
|
<Route path="settings" element={<Settings />} />
|
||||||
|
<Route path="account-linking" element={<AccountLinking />} />
|
||||||
|
</Route>
|
||||||
|
<Route path="login" element={<Login />} />
|
||||||
|
<Route path="register" element={<Register />} />
|
||||||
|
<Route path="forgot-password" element={<ForgotPassword />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import { NavLink } from 'react-router-dom'
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', label: 'Home', icon: HomeIcon },
|
||||||
|
{ to: '/purchases', label: 'Purchases', icon: ReceiptIcon },
|
||||||
|
{ to: '/products', label: 'Products', icon: SearchIcon },
|
||||||
|
{ to: '/coupons', label: 'Coupons', icon: TagIcon },
|
||||||
|
{ to: '/settings', label: 'Settings', icon: GearIcon },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function BottomNav() {
|
||||||
|
return (
|
||||||
|
<nav className="fixed bottom-0 left-0 right-0 z-50 border-t border-gray-200 bg-white safe-area-pb">
|
||||||
|
<div className="mx-auto flex max-w-lg items-center justify-around">
|
||||||
|
{navItems.map(({ to, label, icon: Icon }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex min-h-12 min-w-12 flex-col items-center justify-center px-2 py-2 text-xs ${
|
||||||
|
isActive ? 'text-brand-blue' : 'text-gray-500'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Icon className="mb-0.5 h-6 w-6" />
|
||||||
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function HomeIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m2.25 12 8.954-8.955a1.126 1.126 0 0 1 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReceiptIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h3.75M9 15h3.75M9 18h3.75m3 .75H18a2.25 2.25 0 0 0 2.25-2.25V6.108c0-1.135-.845-2.098-1.976-2.192a48.424 48.424 0 0 0-1.123-.08m-5.801 0c-.065.21-.1.433-.1.664 0 .414.336.75.75.75h4.5a.75.75 0 0 0 .75-.75 2.25 2.25 0 0 0-.1-.664m-5.8 0A2.251 2.251 0 0 1 13.5 2.25H15a2.25 2.25 0 0 1 2.15 1.586m-5.8 0c-.376.023-.75.05-1.124.08C9.095 4.01 8.25 4.973 8.25 6.108V8.25m0 0H4.875c-.621 0-1.125.504-1.125 1.125v11.25c0 .621.504 1.125 1.125 1.125h9.75c.621 0 1.125-.504 1.125-1.125V9.375c0-.621-.504-1.125-1.125-1.125H8.25Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SearchIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TagIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GearIcon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.941-1.11.941h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.087.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import { MemoryRouter } from 'react-router-dom'
|
||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { BottomNav } from './BottomNav.tsx'
|
||||||
|
|
||||||
|
describe('BottomNav', () => {
|
||||||
|
it('renders all navigation items', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<BottomNav />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Purchases')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Products')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Coupons')).toBeInTheDocument()
|
||||||
|
expect(screen.getByText('Settings')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders navigation links with correct paths', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<BottomNav />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const links = screen.getAllByRole('link')
|
||||||
|
const hrefs = links.map((link) => link.getAttribute('href'))
|
||||||
|
|
||||||
|
expect(hrefs).toContain('/')
|
||||||
|
expect(hrefs).toContain('/purchases')
|
||||||
|
expect(hrefs).toContain('/products')
|
||||||
|
expect(hrefs).toContain('/coupons')
|
||||||
|
expect(hrefs).toContain('/settings')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has touch-friendly minimum sizes (48px)', () => {
|
||||||
|
render(
|
||||||
|
<MemoryRouter>
|
||||||
|
<BottomNav />
|
||||||
|
</MemoryRouter>,
|
||||||
|
)
|
||||||
|
|
||||||
|
const links = screen.getAllByRole('link')
|
||||||
|
links.forEach((link) => {
|
||||||
|
expect(link.className).toContain('min-h-12')
|
||||||
|
expect(link.className).toContain('min-w-12')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Outlet } from 'react-router-dom'
|
||||||
|
import { BottomNav } from './BottomNav.tsx'
|
||||||
|
|
||||||
|
export function Layout() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 pb-20">
|
||||||
|
<main className="mx-auto max-w-lg px-4 pt-4">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
<BottomNav />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
const storeColors: Record<string, string> = {
|
||||||
|
meijer: 'bg-meijer-red',
|
||||||
|
kroger: 'bg-kroger-blue',
|
||||||
|
target: 'bg-target-red',
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeLetters: Record<string, string> = {
|
||||||
|
meijer: 'M',
|
||||||
|
kroger: 'K',
|
||||||
|
target: 'T',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StoreIcon({ storeId, size = 'md' }: { storeId: string; size?: 'sm' | 'md' }) {
|
||||||
|
const sizeClass = size === 'sm' ? 'h-6 w-6 text-xs' : 'h-8 w-8 text-sm'
|
||||||
|
const bg = storeColors[storeId] ?? 'bg-gray-400'
|
||||||
|
const letter = storeLetters[storeId] ?? storeId.charAt(0).toUpperCase()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`inline-flex shrink-0 items-center justify-center rounded-full font-bold text-white ${bg} ${sizeClass}`}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { api } from '../lib/api.ts'
|
||||||
|
import type { Purchase, Product, Coupon, PriceAlert, PriceHistory } from '../types/api.ts'
|
||||||
|
|
||||||
|
export function usePurchases() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['purchases'],
|
||||||
|
queryFn: () => api.get<Purchase[]>('/purchases'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePurchase(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['purchases', id],
|
||||||
|
queryFn: () => api.get<Purchase>(`/purchases/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProducts(search?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['products', search],
|
||||||
|
queryFn: () => api.get<Product[]>(`/products${search ? `?q=${encodeURIComponent(search)}` : ''}`),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useProduct(id: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['products', id],
|
||||||
|
queryFn: () => api.get<Product>(`/products/${id}`),
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePriceHistory(productId: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['priceHistory', productId],
|
||||||
|
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
|
||||||
|
enabled: !!productId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCoupons() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['coupons'],
|
||||||
|
queryFn: () => api.get<Coupon[]>('/coupons'),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePriceAlerts() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['priceAlerts'],
|
||||||
|
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-blue: #1e40af;
|
||||||
|
--color-brand-blue-light: #3b82f6;
|
||||||
|
--color-kroger-blue: #0068a8;
|
||||||
|
--color-meijer-red: #e31837;
|
||||||
|
--color-target-red: #cc0000;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
|
||||||
|
|
||||||
|
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = useAuthStore.getState().token
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
useAuthStore.getState().logout()
|
||||||
|
throw new Error('Unauthorized')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`API error: ${res.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json() as Promise<T>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
get: <T>(path: string) => apiFetch<T>(path),
|
||||||
|
post: <T>(path: string, body: unknown) =>
|
||||||
|
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
put: <T>(path: string, body: unknown) =>
|
||||||
|
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
|
||||||
|
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type { Purchase, Product, PriceHistory, Coupon, PriceAlert, User } from '../types/api.ts'
|
||||||
|
|
||||||
|
export const mockUser: User = {
|
||||||
|
id: 'u1',
|
||||||
|
email: 'sam@example.com',
|
||||||
|
name: 'Sam Johnson',
|
||||||
|
connectedStores: ['meijer', 'kroger'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockPurchases: Purchase[] = [
|
||||||
|
{
|
||||||
|
id: 'p1',
|
||||||
|
storeId: 'meijer',
|
||||||
|
storeName: 'Meijer',
|
||||||
|
date: '2026-03-15',
|
||||||
|
total: 47.23,
|
||||||
|
items: [
|
||||||
|
{ id: 'i1', productId: 'prod1', name: 'Whole Milk (1 gal)', quantity: 1, price: 3.49, unitPrice: 3.49 },
|
||||||
|
{ id: 'i2', productId: 'prod2', name: 'Bananas (bunch)', quantity: 1, price: 1.29, unitPrice: 1.29 },
|
||||||
|
{ id: 'i3', productId: 'prod3', name: 'Chicken Breast (2 lb)', quantity: 1, price: 9.98, unitPrice: 4.99 },
|
||||||
|
{ id: 'i4', productId: 'prod4', name: 'Cheddar Cheese (8 oz)', quantity: 2, price: 7.58, unitPrice: 3.79 },
|
||||||
|
{ id: 'i5', productId: 'prod5', name: 'Sourdough Bread', quantity: 1, price: 4.29, unitPrice: 4.29 },
|
||||||
|
{ id: 'i6', productId: 'prod6', name: 'Baby Spinach (5 oz)', quantity: 1, price: 3.99, unitPrice: 3.99 },
|
||||||
|
{ id: 'i7', productId: 'prod7', name: 'Greek Yogurt (32 oz)', quantity: 1, price: 5.49, unitPrice: 5.49 },
|
||||||
|
{ id: 'i8', productId: 'prod8', name: 'Pasta Sauce', quantity: 1, price: 3.79, unitPrice: 3.79 },
|
||||||
|
{ id: 'i9', productId: 'prod9', name: 'Spaghetti (16 oz)', quantity: 1, price: 1.89, unitPrice: 1.89 },
|
||||||
|
{ id: 'i10', productId: 'prod10', name: 'Eggs (dozen)', quantity: 1, price: 5.44, unitPrice: 5.44 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p2',
|
||||||
|
storeId: 'kroger',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
date: '2026-03-12',
|
||||||
|
total: 32.87,
|
||||||
|
items: [
|
||||||
|
{ id: 'i11', productId: 'prod1', name: 'Whole Milk (1 gal)', quantity: 1, price: 3.29, unitPrice: 3.29 },
|
||||||
|
{ id: 'i12', productId: 'prod10', name: 'Eggs (dozen)', quantity: 1, price: 5.29, unitPrice: 5.29 },
|
||||||
|
{ id: 'i13', productId: 'prod11', name: 'Orange Juice (52 oz)', quantity: 1, price: 4.49, unitPrice: 4.49 },
|
||||||
|
{ id: 'i14', productId: 'prod12', name: 'Ground Beef (1 lb)', quantity: 2, price: 11.98, unitPrice: 5.99 },
|
||||||
|
{ id: 'i15', productId: 'prod2', name: 'Bananas (bunch)', quantity: 1, price: 0.99, unitPrice: 0.99 },
|
||||||
|
{ id: 'i16', productId: 'prod13', name: 'Tortilla Chips', quantity: 1, price: 3.49, unitPrice: 3.49 },
|
||||||
|
{ id: 'i17', productId: 'prod14', name: 'Salsa (16 oz)', quantity: 1, price: 3.34, unitPrice: 3.34 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p3',
|
||||||
|
storeId: 'meijer',
|
||||||
|
storeName: 'Meijer',
|
||||||
|
date: '2026-03-08',
|
||||||
|
total: 61.45,
|
||||||
|
items: [
|
||||||
|
{ id: 'i18', productId: 'prod3', name: 'Chicken Breast (2 lb)', quantity: 2, price: 19.96, unitPrice: 4.99 },
|
||||||
|
{ id: 'i19', productId: 'prod15', name: 'Rice (5 lb)', quantity: 1, price: 6.99, unitPrice: 6.99 },
|
||||||
|
{ id: 'i20', productId: 'prod6', name: 'Baby Spinach (5 oz)', quantity: 2, price: 7.98, unitPrice: 3.99 },
|
||||||
|
{ id: 'i21', productId: 'prod16', name: 'Olive Oil (16 oz)', quantity: 1, price: 8.99, unitPrice: 8.99 },
|
||||||
|
{ id: 'i22', productId: 'prod5', name: 'Sourdough Bread', quantity: 1, price: 4.29, unitPrice: 4.29 },
|
||||||
|
{ id: 'i23', productId: 'prod17', name: 'Butter (1 lb)', quantity: 1, price: 4.79, unitPrice: 4.79 },
|
||||||
|
{ id: 'i24', productId: 'prod18', name: 'Avocados (3 ct)', quantity: 1, price: 4.99, unitPrice: 4.99 },
|
||||||
|
{ id: 'i25', productId: 'prod19', name: 'Cereal (Family Size)', quantity: 1, price: 3.46, unitPrice: 3.46 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'p4',
|
||||||
|
storeId: 'target',
|
||||||
|
storeName: 'Target',
|
||||||
|
date: '2026-03-05',
|
||||||
|
total: 28.76,
|
||||||
|
items: [
|
||||||
|
{ id: 'i26', productId: 'prod20', name: 'Paper Towels (6 pk)', quantity: 1, price: 12.99, unitPrice: 12.99 },
|
||||||
|
{ id: 'i27', productId: 'prod21', name: 'Dish Soap', quantity: 1, price: 3.49, unitPrice: 3.49 },
|
||||||
|
{ id: 'i28', productId: 'prod22', name: 'Trash Bags (45 ct)', quantity: 1, price: 8.79, unitPrice: 8.79 },
|
||||||
|
{ id: 'i29', productId: 'prod23', name: 'Hand Soap (2 pk)', quantity: 1, price: 3.49, unitPrice: 3.49 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockProducts: Product[] = [
|
||||||
|
{
|
||||||
|
id: 'prod1',
|
||||||
|
name: 'Whole Milk (1 gal)',
|
||||||
|
brand: 'Store Brand',
|
||||||
|
category: 'Dairy',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 3.49, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 3.29, lastUpdated: '2026-03-14' },
|
||||||
|
{ storeId: 'target', storeName: 'Target', price: 3.59, lastUpdated: '2026-03-13' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod10',
|
||||||
|
name: 'Eggs (dozen)',
|
||||||
|
brand: 'Store Brand',
|
||||||
|
category: 'Dairy',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 5.44, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 5.29, lastUpdated: '2026-03-14' },
|
||||||
|
{ storeId: 'target', storeName: 'Target', price: 5.69, lastUpdated: '2026-03-13' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod3',
|
||||||
|
name: 'Chicken Breast (2 lb)',
|
||||||
|
brand: 'Store Brand',
|
||||||
|
category: 'Meat',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 9.98, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 10.49, lastUpdated: '2026-03-14' },
|
||||||
|
{ storeId: 'target', storeName: 'Target', price: 10.99, lastUpdated: '2026-03-13' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod2',
|
||||||
|
name: 'Bananas (bunch)',
|
||||||
|
brand: 'Dole',
|
||||||
|
category: 'Produce',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 1.29, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 0.99, lastUpdated: '2026-03-14' },
|
||||||
|
{ storeId: 'target', storeName: 'Target', price: 1.19, lastUpdated: '2026-03-13' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod5',
|
||||||
|
name: 'Sourdough Bread',
|
||||||
|
brand: 'Artisan Hearth',
|
||||||
|
category: 'Bakery',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 4.29, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 4.49, lastUpdated: '2026-03-14' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod6',
|
||||||
|
name: 'Baby Spinach (5 oz)',
|
||||||
|
brand: 'Organic Girl',
|
||||||
|
category: 'Produce',
|
||||||
|
prices: [
|
||||||
|
{ storeId: 'meijer', storeName: 'Meijer', price: 3.99, lastUpdated: '2026-03-15' },
|
||||||
|
{ storeId: 'kroger', storeName: 'Kroger', price: 3.79, lastUpdated: '2026-03-14' },
|
||||||
|
{ storeId: 'target', storeName: 'Target', price: 4.19, lastUpdated: '2026-03-13' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function getMockPriceHistory(productId: string): PriceHistory[] {
|
||||||
|
const basePrice = productId === 'prod1' ? 3.29
|
||||||
|
: productId === 'prod10' ? 3.49
|
||||||
|
: productId === 'prod3' ? 8.99
|
||||||
|
: productId === 'prod2' ? 0.89
|
||||||
|
: 3.99
|
||||||
|
|
||||||
|
const stores = ['meijer', 'kroger', 'target']
|
||||||
|
const history: PriceHistory[] = []
|
||||||
|
|
||||||
|
for (let i = 90; i >= 0; i -= 7) {
|
||||||
|
const date = new Date(2026, 2, 17)
|
||||||
|
date.setDate(date.getDate() - i)
|
||||||
|
const dateStr = date.toISOString().split('T')[0]
|
||||||
|
|
||||||
|
for (const store of stores) {
|
||||||
|
const storeOffset = store === 'meijer' ? 0.10 : store === 'target' ? 0.20 : 0
|
||||||
|
// simulate price variation over time
|
||||||
|
const spike = (i > 30 && i < 50) ? 0.80 : 0
|
||||||
|
const drift = (90 - i) * 0.005
|
||||||
|
history.push({
|
||||||
|
date: dateStr,
|
||||||
|
price: Math.round((basePrice + storeOffset + spike + drift) * 100) / 100,
|
||||||
|
storeId: store,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mockCoupons: Coupon[] = [
|
||||||
|
{ id: 'c1', productId: 'prod1', storeName: 'Kroger', description: '$0.50 off Whole Milk (1 gal)', discount: '$0.50', expiresAt: '2026-03-31', code: 'MILK50' },
|
||||||
|
{ id: 'c2', storeName: 'Meijer', description: '10% off Meat Department', discount: '10%', expiresAt: '2026-03-22' },
|
||||||
|
{ id: 'c3', productId: 'prod6', storeName: 'Kroger', description: 'Buy 2 Get 1 Free — Baby Spinach', discount: 'B2G1', expiresAt: '2026-04-05' },
|
||||||
|
{ id: 'c4', storeName: 'Target', description: '$5 off $40+ Grocery Purchase', discount: '$5.00', expiresAt: '2026-03-28', code: 'SAVE5' },
|
||||||
|
{ id: 'c5', productId: 'prod10', storeName: 'Meijer', description: '$1 off Eggs (dozen)', discount: '$1.00', expiresAt: '2026-03-25' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const mockAlerts: PriceAlert[] = [
|
||||||
|
{ id: 'a1', productId: 'prod10', productName: 'Eggs (dozen)', targetPrice: 4.99, currentPrice: 5.29, triggered: false },
|
||||||
|
{ id: 'a2', productId: 'prod2', productName: 'Bananas (bunch)', targetPrice: 1.09, currentPrice: 0.99, triggered: true },
|
||||||
|
{ id: 'a3', productId: 'prod3', productName: 'Chicken Breast (2 lb)', targetPrice: 9.50, currentPrice: 9.98, triggered: false },
|
||||||
|
{ id: 'a4', productId: 'prod1', productName: 'Whole Milk (1 gal)', targetPrice: 3.39, currentPrice: 3.29, triggered: true },
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
interface StoreConfig {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
fields: { key: string; label: string; type: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableStores: StoreConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'meijer',
|
||||||
|
name: 'Meijer',
|
||||||
|
description: 'Connect your mPerks account to import purchase history.',
|
||||||
|
fields: [
|
||||||
|
{ key: 'email', label: 'mPerks Email', type: 'email' },
|
||||||
|
{ key: 'password', label: 'mPerks Password', type: 'password' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kroger',
|
||||||
|
name: 'Kroger',
|
||||||
|
description: 'Connect your Kroger Plus account for receipts and digital coupons.',
|
||||||
|
fields: [
|
||||||
|
{ key: 'email', label: 'Kroger Email', type: 'email' },
|
||||||
|
{ key: 'password', label: 'Kroger Password', type: 'password' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'target',
|
||||||
|
name: 'Target',
|
||||||
|
description: 'Connect Target Circle for purchase history and deals.',
|
||||||
|
fields: [
|
||||||
|
{ key: 'email', label: 'Target Email', type: 'email' },
|
||||||
|
{ key: 'password', label: 'Target Password', type: 'password' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function AccountLinking() {
|
||||||
|
const [linking, setLinking] = useState<string | null>(null)
|
||||||
|
const [connected, setConnected] = useState<string[]>(['meijer', 'kroger'])
|
||||||
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
|
function handleConnect(storeId: string) {
|
||||||
|
setStatus('connecting')
|
||||||
|
// Simulate connection
|
||||||
|
setTimeout(() => {
|
||||||
|
setConnected((prev) => [...prev, storeId])
|
||||||
|
setStatus('success')
|
||||||
|
setTimeout(() => {
|
||||||
|
setLinking(null)
|
||||||
|
setStatus('idle')
|
||||||
|
}, 1500)
|
||||||
|
}, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDisconnect(storeId: string) {
|
||||||
|
setConnected((prev) => prev.filter((s) => s !== storeId))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Link to="/settings" className="inline-flex items-center gap-1 text-sm text-brand-blue">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-gray-900">Connect a Store</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
|
Link your store loyalty accounts to automatically import purchases and track prices.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 space-y-4">
|
||||||
|
{availableStores.map((store) => {
|
||||||
|
const isConnected = connected.includes(store.id)
|
||||||
|
const isLinking = linking === store.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={store.id} className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StoreIcon storeId={store.id} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{store.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{isConnected ? 'Connected' : store.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isConnected ? (
|
||||||
|
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs text-white">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isConnected && !isLinking && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleDisconnect(store.id)}
|
||||||
|
className="mt-3 min-h-10 w-full rounded-xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 active:bg-red-50"
|
||||||
|
>
|
||||||
|
Disconnect
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isConnected && !isLinking && (
|
||||||
|
<button
|
||||||
|
onClick={() => setLinking(store.id)}
|
||||||
|
className="mt-3 min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Connect {store.name}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLinking && (
|
||||||
|
<LinkForm
|
||||||
|
store={store}
|
||||||
|
status={status}
|
||||||
|
onSubmit={() => handleConnect(store.id)}
|
||||||
|
onCancel={() => {
|
||||||
|
setLinking(null)
|
||||||
|
setStatus('idle')
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 rounded-xl bg-blue-50 p-4">
|
||||||
|
<p className="text-xs text-blue-700">
|
||||||
|
Your credentials are encrypted and stored securely. CartSnitch never shares your login
|
||||||
|
information with third parties.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkForm({
|
||||||
|
store,
|
||||||
|
status,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: {
|
||||||
|
store: StoreConfig
|
||||||
|
status: string
|
||||||
|
onSubmit: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{store.fields.map((field) => (
|
||||||
|
<input
|
||||||
|
key={field.key}
|
||||||
|
type={field.type}
|
||||||
|
placeholder={field.label}
|
||||||
|
autoComplete={field.type === 'password' ? 'current-password' : field.type}
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{status === 'connecting' && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl bg-blue-50 px-4 py-3">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
|
||||||
|
<span className="text-sm text-blue-700">Connecting to {store.name}...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'success' && (
|
||||||
|
<div className="rounded-xl bg-green-50 px-4 py-3 text-sm text-green-700">
|
||||||
|
Connected successfully!
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'error' && (
|
||||||
|
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
Connection failed. Please check your credentials and try again.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status === 'idle' && (
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
onClick={onSubmit}
|
||||||
|
className="min-h-12 flex-1 rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { mockAlerts } from '../lib/mock-data.ts'
|
||||||
|
import type { PriceAlert } from '../types/api.ts'
|
||||||
|
|
||||||
|
export function Alerts() {
|
||||||
|
const [alerts, setAlerts] = useState<PriceAlert[]>(mockAlerts)
|
||||||
|
const [showCreate, setShowCreate] = useState(false)
|
||||||
|
|
||||||
|
const triggered = alerts.filter((a) => a.triggered)
|
||||||
|
const watching = alerts.filter((a) => !a.triggered)
|
||||||
|
|
||||||
|
function handleDelete(id: string) {
|
||||||
|
setAlerts((prev) => prev.filter((a) => a.id !== id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Price Alerts</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreate(!showCreate)}
|
||||||
|
className="min-h-10 rounded-full bg-brand-blue px-4 text-sm font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
+ New Alert
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create alert form */}
|
||||||
|
{showCreate && <CreateAlertForm onClose={() => setShowCreate(false)} onCreated={(a) => {
|
||||||
|
setAlerts((prev) => [a, ...prev])
|
||||||
|
setShowCreate(false)
|
||||||
|
}} />}
|
||||||
|
|
||||||
|
{/* Triggered alerts */}
|
||||||
|
{triggered.length > 0 && (
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-green-700">
|
||||||
|
Triggered ({triggered.length})
|
||||||
|
</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{triggered.map((alert) => (
|
||||||
|
<AlertCard key={alert.id} alert={alert} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Watching alerts */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">
|
||||||
|
Watching ({watching.length})
|
||||||
|
</h2>
|
||||||
|
{watching.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
No active alerts.{' '}
|
||||||
|
<Link to="/products" className="text-brand-blue">
|
||||||
|
Search products
|
||||||
|
</Link>{' '}
|
||||||
|
to set one up.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{watching.map((alert) => (
|
||||||
|
<AlertCard key={alert.id} alert={alert} onDelete={handleDelete} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertCard({
|
||||||
|
alert,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
alert: PriceAlert
|
||||||
|
onDelete: (id: string) => void
|
||||||
|
}) {
|
||||||
|
const priceDiff = alert.currentPrice - alert.targetPrice
|
||||||
|
const isBelow = priceDiff <= 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`rounded-xl p-4 shadow-sm ${
|
||||||
|
alert.triggered ? 'border border-green-200 bg-green-50' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Link to={`/products/${alert.productId}`} className="text-sm font-medium text-gray-900">
|
||||||
|
{alert.productName}
|
||||||
|
</Link>
|
||||||
|
<div className="mt-1 flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">Target: ${alert.targetPrice.toFixed(2)}</span>
|
||||||
|
<span className="text-xs text-gray-400">·</span>
|
||||||
|
<span className={`text-xs font-medium ${isBelow ? 'text-green-700' : 'text-gray-500'}`}>
|
||||||
|
Now: ${alert.currentPrice.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{alert.triggered && (
|
||||||
|
<p className="mt-1 text-xs font-medium text-green-700">
|
||||||
|
Price dropped ${Math.abs(priceDiff).toFixed(2)} below target
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status indicator */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{alert.triggered && (
|
||||||
|
<span className="flex h-3 w-3 rounded-full bg-green-500" />
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete(alert.id)}
|
||||||
|
className="min-h-10 min-w-10 rounded-lg p-2 text-gray-400 active:bg-gray-100"
|
||||||
|
aria-label="Delete alert"
|
||||||
|
>
|
||||||
|
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar toward target */}
|
||||||
|
{!alert.triggered && (
|
||||||
|
<div className="mt-3">
|
||||||
|
<div className="h-1.5 rounded-full bg-gray-100">
|
||||||
|
<div
|
||||||
|
className="h-1.5 rounded-full bg-brand-blue-light"
|
||||||
|
style={{
|
||||||
|
width: `${Math.min(100, Math.max(5, (1 - priceDiff / alert.currentPrice) * 100))}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateAlertForm({
|
||||||
|
onClose,
|
||||||
|
onCreated,
|
||||||
|
}: {
|
||||||
|
onClose: () => void
|
||||||
|
onCreated: (alert: PriceAlert) => void
|
||||||
|
}) {
|
||||||
|
const [productName, setProductName] = useState('')
|
||||||
|
const [targetPrice, setTargetPrice] = useState('')
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (!productName || !targetPrice) return
|
||||||
|
|
||||||
|
onCreated({
|
||||||
|
id: `a-${Date.now()}`,
|
||||||
|
productId: `prod-${Date.now()}`,
|
||||||
|
productName,
|
||||||
|
targetPrice: parseFloat(targetPrice),
|
||||||
|
currentPrice: parseFloat(targetPrice) + 0.50,
|
||||||
|
triggered: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit} className="mt-4 space-y-3 rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Product name"
|
||||||
|
value={productName}
|
||||||
|
onChange={(e) => setProductName(e.target.value)}
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
placeholder="Target price"
|
||||||
|
value={targetPrice}
|
||||||
|
onChange={(e) => setTargetPrice(e.target.value)}
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="min-h-12 flex-1 rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Create Alert
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { mockCoupons } from '../lib/mock-data.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
export function Coupons() {
|
||||||
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
|
|
||||||
|
function handleCopy(code: string, id: string) {
|
||||||
|
navigator.clipboard?.writeText(code)
|
||||||
|
setCopied(id)
|
||||||
|
setTimeout(() => setCopied(null), 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const storeIds: Record<string, string> = {
|
||||||
|
Meijer: 'meijer',
|
||||||
|
Kroger: 'kroger',
|
||||||
|
Target: 'target',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Coupons & Deals</h1>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{mockCoupons.map((coupon) => {
|
||||||
|
const isExpiringSoon =
|
||||||
|
new Date(coupon.expiresAt).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={coupon.id} className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<StoreIcon storeId={storeIds[coupon.storeName] ?? 'unknown'} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{coupon.description}</p>
|
||||||
|
<p className="mt-0.5 text-xs text-gray-500">{coupon.storeName}</p>
|
||||||
|
<p
|
||||||
|
className={`mt-1 text-xs ${
|
||||||
|
isExpiringSoon ? 'font-medium text-orange-600' : 'text-gray-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Expires{' '}
|
||||||
|
{new Date(coupon.expiresAt).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
{isExpiringSoon && ' — expiring soon!'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="shrink-0 rounded-lg bg-green-100 px-2 py-1 text-sm font-bold text-green-700">
|
||||||
|
{coupon.discount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{coupon.code && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleCopy(coupon.code!, coupon.id)}
|
||||||
|
className="mt-3 flex min-h-10 w-full items-center justify-center gap-2 rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm font-mono active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<span className="text-gray-700">{coupon.code}</span>
|
||||||
|
<span className="text-xs text-brand-blue">
|
||||||
|
{copied === coupon.id ? 'Copied!' : 'Tap to copy'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { LineChart, Line, ResponsiveContainer } from 'recharts'
|
||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
import { mockPurchases, mockAlerts, getMockPriceHistory } from '../lib/mock-data.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
const sparklineData = getMockPriceHistory('prod10').filter((p) => p.storeId === 'meijer').slice(-8)
|
||||||
|
const milkSparkline = getMockPriceHistory('prod1').filter((p) => p.storeId === 'kroger').slice(-8)
|
||||||
|
|
||||||
|
export function Dashboard() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||||
|
|
||||||
|
const triggeredAlerts = mockAlerts.filter((a) => a.triggered)
|
||||||
|
const watchingAlerts = mockAlerts.filter((a) => !a.triggered)
|
||||||
|
const recentPurchases = mockPurchases.slice(0, 3)
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
|
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
|
<div className="mt-8 space-y-3">
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/register"
|
||||||
|
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Create Account
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Hi, {user?.name?.split(' ')[0] ?? 'there'}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{/* Triggered alerts banner */}
|
||||||
|
{triggeredAlerts.length > 0 && (
|
||||||
|
<Link
|
||||||
|
to="/alerts"
|
||||||
|
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
|
||||||
|
>
|
||||||
|
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
|
||||||
|
✓
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-green-800">
|
||||||
|
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-green-700">
|
||||||
|
{triggeredAlerts.map((a) => a.productName).join(', ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Quick stats */}
|
||||||
|
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-medium text-gray-500">Watching</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
|
||||||
|
<p className="text-xs text-gray-400">price alerts</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<p className="text-xs font-medium text-gray-500">This Month</p>
|
||||||
|
<p className="mt-1 text-2xl font-bold text-gray-900">
|
||||||
|
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400">grocery spend</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price trend sparklines */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<SparklineCard label="Eggs (dozen)" data={sparklineData} current="$5.44" />
|
||||||
|
<SparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current="$3.29" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Recent purchases */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
|
||||||
|
<Link to="/purchases" className="text-sm text-brand-blue">
|
||||||
|
View all
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
{recentPurchases.map((purchase) => (
|
||||||
|
<Link
|
||||||
|
key={purchase.id}
|
||||||
|
to={`/purchases/${purchase.id}`}
|
||||||
|
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<StoreIcon storeId={purchase.storeId} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(purchase.date).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}{' '}
|
||||||
|
· {purchase.items.length} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-sm font-semibold text-gray-900">
|
||||||
|
${purchase.total.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<section className="mt-6 pb-4">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Link
|
||||||
|
to="/products"
|
||||||
|
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Compare Prices
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Link a Store
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SparklineCard({
|
||||||
|
label,
|
||||||
|
data,
|
||||||
|
current,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
data: { date: string; price: number }[]
|
||||||
|
current: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{label}</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900">{current}</p>
|
||||||
|
</div>
|
||||||
|
<div className="h-10 w-24">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="price"
|
||||||
|
stroke="#1e40af"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
|
||||||
|
export function ForgotPassword() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [submitted, setSubmitted] = useState(false)
|
||||||
|
|
||||||
|
function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
if (email) setSubmitted(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">Reset Password</h1>
|
||||||
|
<p className="mb-8 max-w-sm text-center text-sm text-gray-500">
|
||||||
|
Enter your email and we'll send you a link to reset your password.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{submitted ? (
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-green-50 px-4 py-4 text-center">
|
||||||
|
<p className="text-sm text-green-700">
|
||||||
|
If an account exists for <strong>{email}</strong>, you'll receive a reset link shortly.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/login"
|
||||||
|
className="mt-4 inline-block min-h-12 rounded-xl bg-brand-blue px-6 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Back to Sign In
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Send Reset Link
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
|
<Link to="/login" className="text-brand-blue">
|
||||||
|
Back to Sign In
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
import { api } from '../lib/api.ts'
|
||||||
|
import { mockUser } from '../lib/mock-data.ts'
|
||||||
|
import type { User } from '../types/api.ts'
|
||||||
|
|
||||||
|
export function Login() {
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
setError('Please fill in all fields.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
|
||||||
|
setAuth(res.user, res.token)
|
||||||
|
navigate('/')
|
||||||
|
} catch {
|
||||||
|
// Fallback to mock auth for demo
|
||||||
|
setAuth(mockUser, 'mock-jwt-token')
|
||||||
|
navigate('/')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
|
||||||
|
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Signing in...' : 'Sign In'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link to="/register" className="text-brand-blue">
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { mockProducts, getMockPriceHistory } from '../lib/mock-data.ts'
|
||||||
|
|
||||||
|
const storeLineColors: Record<string, string> = {
|
||||||
|
meijer: '#e31837',
|
||||||
|
kroger: '#0068a8',
|
||||||
|
target: '#cc0000',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProductDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const product = mockProducts.find((p) => p.id === id)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Product not found.</p>
|
||||||
|
<Link to="/products" className="mt-4 inline-block text-sm text-brand-blue">
|
||||||
|
Back to products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const history = getMockPriceHistory(product.id)
|
||||||
|
const lowestPrice = Math.min(...product.prices.map((p) => p.price))
|
||||||
|
|
||||||
|
// Reshape history for chart: { date, meijer, kroger, target }
|
||||||
|
const chartData: Record<string, string | number>[] = []
|
||||||
|
const dateMap = new Map<string, Record<string, string | number>>()
|
||||||
|
for (const h of history) {
|
||||||
|
if (!dateMap.has(h.date)) {
|
||||||
|
dateMap.set(h.date, { date: h.date })
|
||||||
|
}
|
||||||
|
dateMap.get(h.date)![h.storeId] = h.price
|
||||||
|
}
|
||||||
|
for (const entry of dateMap.values()) {
|
||||||
|
chartData.push(entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<Link to="/products" className="inline-flex items-center gap-1 text-sm text-brand-blue">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Products
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Product header */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{product.brand} · {product.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price history chart */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price History (90 days)</h2>
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="h-52">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={chartData}>
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
tickFormatter={(d: string) => {
|
||||||
|
const dt = new Date(d)
|
||||||
|
return `${dt.getMonth() + 1}/${dt.getDate()}`
|
||||||
|
}}
|
||||||
|
interval="preserveStartEnd"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 10 }}
|
||||||
|
domain={['auto', 'auto']}
|
||||||
|
tickFormatter={(v: number) => `$${v.toFixed(2)}`}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value) => `$${Number(value).toFixed(2)}`}
|
||||||
|
labelFormatter={(label) =>
|
||||||
|
new Date(String(label)).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
{['meijer', 'kroger', 'target'].map((store) => (
|
||||||
|
<Line
|
||||||
|
key={store}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={store}
|
||||||
|
name={store.charAt(0).toUpperCase() + store.slice(1)}
|
||||||
|
stroke={storeLineColors[store]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Store comparison table */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Store Comparison</h2>
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{product.prices
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.price - b.price)
|
||||||
|
.map((pp) => (
|
||||||
|
<div
|
||||||
|
key={pp.storeId}
|
||||||
|
className="flex items-center justify-between px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white ${
|
||||||
|
pp.storeId === 'meijer'
|
||||||
|
? 'bg-meijer-red'
|
||||||
|
: pp.storeId === 'kroger'
|
||||||
|
? 'bg-kroger-blue'
|
||||||
|
: 'bg-target-red'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pp.storeName.charAt(0)}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{pp.storeName}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Updated{' '}
|
||||||
|
{new Date(pp.lastUpdated).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-sm font-bold ${
|
||||||
|
pp.price === lowestPrice ? 'text-green-700' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${pp.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{pp.price === lowestPrice && (
|
||||||
|
<span className="text-xs text-green-600">Best price</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="mt-6 space-y-3 pb-4">
|
||||||
|
<Link
|
||||||
|
to="/alerts"
|
||||||
|
className="flex min-h-12 items-center justify-center rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
Set Price Alert
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to={`/compare/${product.id}`}
|
||||||
|
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
Compare at Nearby Stores
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState, useMemo } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { mockProducts } from '../lib/mock-data.ts'
|
||||||
|
|
||||||
|
export function Products() {
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!search.trim()) return mockProducts
|
||||||
|
const q = search.toLowerCase()
|
||||||
|
return mockProducts.filter(
|
||||||
|
(p) =>
|
||||||
|
p.name.toLowerCase().includes(q) ||
|
||||||
|
p.brand.toLowerCase().includes(q) ||
|
||||||
|
p.category.toLowerCase().includes(q),
|
||||||
|
)
|
||||||
|
}, [search])
|
||||||
|
|
||||||
|
const lowestPrice = (product: typeof mockProducts[0]) =>
|
||||||
|
Math.min(...product.prices.map((p) => p.price))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
|
||||||
|
|
||||||
|
{/* Search input */}
|
||||||
|
<div className="mt-4">
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Search products..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 bg-white px-4 text-base shadow-sm focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Product list */}
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<p className="text-sm text-gray-500">No products match "{search}".</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((product) => {
|
||||||
|
const low = lowestPrice(product)
|
||||||
|
const cheapest = product.prices.find((p) => p.price === low)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={product.id}
|
||||||
|
to={`/products/${product.id}`}
|
||||||
|
className="block rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{product.name}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{product.brand} · {product.category}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-bold text-green-700">${low.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{cheapest?.storeName}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex gap-2">
|
||||||
|
{product.prices.map((pp) => (
|
||||||
|
<span
|
||||||
|
key={pp.storeId}
|
||||||
|
className={`rounded-full px-2 py-0.5 text-xs ${
|
||||||
|
pp.price === low
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-gray-100 text-gray-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pp.storeName} ${pp.price.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { mockPurchases } from '../lib/mock-data.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
export function PurchaseDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const purchase = mockPurchases.find((p) => p.id === id)
|
||||||
|
|
||||||
|
if (!purchase) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Purchase not found.</p>
|
||||||
|
<Link to="/purchases" className="mt-4 inline-block text-sm text-brand-blue">
|
||||||
|
Back to purchases
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<Link to="/purchases" className="inline-flex items-center gap-1 text-sm text-brand-blue">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
Purchases
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Receipt header */}
|
||||||
|
<div className="mt-4 rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StoreIcon storeId={purchase.storeId} />
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-bold text-gray-900">{purchase.storeName}</h1>
|
||||||
|
<p className="text-sm text-gray-500">
|
||||||
|
{new Date(purchase.date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Line items */}
|
||||||
|
<div className="mt-4 rounded-xl bg-white shadow-sm">
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{purchase.items.map((item) => (
|
||||||
|
<Link
|
||||||
|
key={item.id}
|
||||||
|
to={`/products/${item.productId}`}
|
||||||
|
className="flex items-center justify-between px-4 py-3 active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{item.name}</p>
|
||||||
|
{item.quantity > 1 && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{item.quantity} × ${item.unitPrice.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="ml-4 text-sm font-medium text-gray-900">
|
||||||
|
${item.price.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Total */}
|
||||||
|
<div className="border-t-2 border-gray-200 px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-base font-bold text-gray-900">Total</span>
|
||||||
|
<span className="text-base font-bold text-gray-900">
|
||||||
|
${purchase.total.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { mockPurchases } from '../lib/mock-data.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
const stores = ['all', ...new Set(mockPurchases.map((p) => p.storeName))]
|
||||||
|
|
||||||
|
export function Purchases() {
|
||||||
|
const [storeFilter, setStoreFilter] = useState('all')
|
||||||
|
|
||||||
|
const filtered =
|
||||||
|
storeFilter === 'all'
|
||||||
|
? mockPurchases
|
||||||
|
: mockPurchases.filter((p) => p.storeName === storeFilter)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Purchase History</h1>
|
||||||
|
|
||||||
|
{/* Store filter chips */}
|
||||||
|
<div className="mt-4 flex gap-2 overflow-x-auto pb-1">
|
||||||
|
{stores.map((store) => (
|
||||||
|
<button
|
||||||
|
key={store}
|
||||||
|
onClick={() => setStoreFilter(store)}
|
||||||
|
className={`min-h-10 shrink-0 rounded-full px-4 text-sm font-medium ${
|
||||||
|
storeFilter === store
|
||||||
|
? 'bg-brand-blue text-white'
|
||||||
|
: 'bg-white text-gray-700 shadow-sm'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{store === 'all' ? 'All Stores' : store}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Purchase list */}
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
|
||||||
|
<p className="text-sm text-gray-500">No purchases found for this filter.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filtered.map((purchase) => (
|
||||||
|
<Link
|
||||||
|
key={purchase.id}
|
||||||
|
to={`/purchases/${purchase.id}`}
|
||||||
|
className="block rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StoreIcon storeId={purchase.storeId} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(purchase.date).toLocaleDateString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">${purchase.total.toFixed(2)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{purchase.items.length} items</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Item preview */}
|
||||||
|
<p className="mt-2 truncate text-xs text-gray-400">
|
||||||
|
{purchase.items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((i) => i.name)
|
||||||
|
.join(', ')}
|
||||||
|
{purchase.items.length > 3 && ` +${purchase.items.length - 3} more`}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
import { api } from '../lib/api.ts'
|
||||||
|
import { mockUser } from '../lib/mock-data.ts'
|
||||||
|
import type { User } from '../types/api.ts'
|
||||||
|
|
||||||
|
export function Register() {
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [email, setEmail] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const setAuth = useAuthStore((s) => s.setAuth)
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
|
||||||
|
if (!name || !email || !password) {
|
||||||
|
setError('Please fill in all fields.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 8) {
|
||||||
|
setError('Password must be at least 8 characters.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
|
||||||
|
setAuth(res.user, res.token)
|
||||||
|
navigate('/')
|
||||||
|
} catch {
|
||||||
|
// Fallback to mock auth for demo
|
||||||
|
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
|
||||||
|
navigate('/')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
||||||
|
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
|
||||||
|
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Full Name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoComplete="name"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
placeholder="Email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="email"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
placeholder="Password (min. 8 characters)"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{loading ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-6 text-sm text-gray-500">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link to="/login" className="text-brand-blue">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useAuthStore } from '../stores/auth.ts'
|
||||||
|
import { useThemeStore } from '../stores/theme.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
export function Settings() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const logout = useAuthStore((s) => s.logout)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const { theme, setTheme } = useThemeStore()
|
||||||
|
|
||||||
|
const connectedStores = user?.connectedStores ?? []
|
||||||
|
|
||||||
|
function handleSignOut() {
|
||||||
|
logout()
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
|
||||||
|
{/* Profile section */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Profile</h2>
|
||||||
|
<div className="rounded-xl bg-white p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-blue text-lg font-bold text-white">
|
||||||
|
{user?.name?.charAt(0) ?? '?'}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{user?.name ?? 'Guest'}</p>
|
||||||
|
<p className="truncate text-xs text-gray-500">{user?.email ?? ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Connected stores */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Connected Stores</h2>
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
{connectedStores.length > 0 ? (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{connectedStores.map((storeId) => (
|
||||||
|
<div key={storeId} className="flex items-center gap-3 px-4 py-3">
|
||||||
|
<StoreIcon storeId={storeId} size="sm" />
|
||||||
|
<span className="text-sm font-medium text-gray-900 capitalize">{storeId}</span>
|
||||||
|
<span className="ml-auto text-xs text-green-600">Connected</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4">
|
||||||
|
<p className="text-sm text-gray-500">No stores connected yet.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border-t border-gray-100 p-3">
|
||||||
|
<Link
|
||||||
|
to="/account-linking"
|
||||||
|
className="flex min-h-12 items-center justify-center rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
|
||||||
|
>
|
||||||
|
{connectedStores.length > 0 ? 'Manage Stores' : 'Connect a Store'}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Notifications</h2>
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
<SettingsToggle label="Price alert notifications" defaultChecked />
|
||||||
|
<SettingsToggle label="Weekly deals digest" defaultChecked />
|
||||||
|
<SettingsToggle label="Purchase import confirmations" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Appearance */}
|
||||||
|
<section className="mt-6">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Appearance</h2>
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
<div className="flex gap-2 p-3">
|
||||||
|
{(['light', 'dark', 'system'] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setTheme(t)}
|
||||||
|
className={`min-h-10 flex-1 rounded-lg px-3 py-2 text-sm font-medium capitalize ${
|
||||||
|
theme === t
|
||||||
|
? 'bg-brand-blue text-white'
|
||||||
|
: 'bg-gray-100 text-gray-700 active:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Account actions */}
|
||||||
|
<section className="mt-6 pb-4">
|
||||||
|
<h2 className="mb-3 text-sm font-semibold text-gray-500">Account</h2>
|
||||||
|
<div className="rounded-xl bg-white shadow-sm">
|
||||||
|
<button
|
||||||
|
onClick={handleSignOut}
|
||||||
|
className="min-h-12 w-full rounded-xl px-4 py-3 text-base font-medium text-red-600 active:bg-red-50"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsToggle({
|
||||||
|
label,
|
||||||
|
defaultChecked = false,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
defaultChecked?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex min-h-12 cursor-pointer items-center justify-between px-4 py-3">
|
||||||
|
<span className="text-sm text-gray-900">{label}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
defaultChecked={defaultChecked}
|
||||||
|
className="h-5 w-5 rounded border-gray-300 text-brand-blue focus:ring-brand-blue"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { mockProducts } from '../lib/mock-data.ts'
|
||||||
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
|
export function StoreComparison() {
|
||||||
|
const { productId } = useParams<{ productId: string }>()
|
||||||
|
const product = mockProducts.find((p) => p.id === productId)
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
return (
|
||||||
|
<div className="py-8 text-center">
|
||||||
|
<p className="text-sm text-gray-500">Product not found.</p>
|
||||||
|
<Link to="/products" className="mt-4 inline-block text-sm text-brand-blue">
|
||||||
|
Back to products
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = product.prices.slice().sort((a, b) => a.price - b.price)
|
||||||
|
const lowestPrice = sorted[0]?.price ?? 0
|
||||||
|
const savings = sorted.length > 1 ? sorted[sorted.length - 1].price - sorted[0].price : 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Back link */}
|
||||||
|
<Link to={`/products/${product.id}`} className="inline-flex items-center gap-1 text-sm text-brand-blue">
|
||||||
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
{product.name}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-2xl font-bold text-gray-900">Store Comparison</h1>
|
||||||
|
<p className="mt-1 text-sm text-gray-500">{product.name} · {product.brand}</p>
|
||||||
|
|
||||||
|
{/* Savings banner */}
|
||||||
|
{savings > 0 && (
|
||||||
|
<div className="mt-4 rounded-xl bg-green-50 p-4">
|
||||||
|
<p className="text-sm font-semibold text-green-800">
|
||||||
|
Save ${savings.toFixed(2)} by shopping at {sorted[0].storeName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Store comparison cards */}
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
{sorted.map((pp, idx) => (
|
||||||
|
<div
|
||||||
|
key={pp.storeId}
|
||||||
|
className={`rounded-xl p-4 shadow-sm ${
|
||||||
|
idx === 0 ? 'border-2 border-green-400 bg-white' : 'bg-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<StoreIcon storeId={pp.storeId} />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{pp.storeName}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Updated{' '}
|
||||||
|
{new Date(pp.lastUpdated).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p
|
||||||
|
className={`text-lg font-bold ${
|
||||||
|
pp.price === lowestPrice ? 'text-green-700' : 'text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
${pp.price.toFixed(2)}
|
||||||
|
</p>
|
||||||
|
{pp.price === lowestPrice ? (
|
||||||
|
<span className="text-xs font-medium text-green-600">Best price</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-400">
|
||||||
|
+${(pp.price - lowestPrice).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-center text-xs text-gray-400">
|
||||||
|
Prices last verified from store loyalty card data. Map view coming soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
import type { User } from '../types/api.ts'
|
||||||
|
|
||||||
|
interface AuthState {
|
||||||
|
user: User | null
|
||||||
|
token: string | null
|
||||||
|
isAuthenticated: boolean
|
||||||
|
setAuth: (user: User, token: string) => void
|
||||||
|
logout: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useAuthStore = create<AuthState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
user: null,
|
||||||
|
token: null,
|
||||||
|
isAuthenticated: false,
|
||||||
|
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
||||||
|
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
||||||
|
}),
|
||||||
|
{ name: 'cartsnitch-auth' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { create } from 'zustand'
|
||||||
|
import { persist } from 'zustand/middleware'
|
||||||
|
|
||||||
|
type Theme = 'light' | 'dark' | 'system'
|
||||||
|
|
||||||
|
interface ThemeState {
|
||||||
|
theme: Theme
|
||||||
|
setTheme: (theme: Theme) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useThemeStore = create<ThemeState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
theme: 'system',
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
}),
|
||||||
|
{ name: 'cartsnitch-theme' },
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
import '@testing-library/jest-dom/vitest'
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
export interface Purchase {
|
||||||
|
id: string
|
||||||
|
storeId: string
|
||||||
|
storeName: string
|
||||||
|
date: string
|
||||||
|
total: number
|
||||||
|
items: PurchaseItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PurchaseItem {
|
||||||
|
id: string
|
||||||
|
productId: string
|
||||||
|
name: string
|
||||||
|
quantity: number
|
||||||
|
price: number
|
||||||
|
unitPrice: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
brand: string
|
||||||
|
category: string
|
||||||
|
imageUrl?: string
|
||||||
|
prices: ProductPrice[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProductPrice {
|
||||||
|
storeId: string
|
||||||
|
storeName: string
|
||||||
|
price: number
|
||||||
|
lastUpdated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceHistory {
|
||||||
|
date: string
|
||||||
|
price: number
|
||||||
|
storeId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Coupon {
|
||||||
|
id: string
|
||||||
|
productId?: string
|
||||||
|
storeName: string
|
||||||
|
description: string
|
||||||
|
discount: string
|
||||||
|
expiresAt: string
|
||||||
|
code?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PriceAlert {
|
||||||
|
id: string
|
||||||
|
productId: string
|
||||||
|
productName: string
|
||||||
|
targetPrice: number
|
||||||
|
currentPrice: number
|
||||||
|
triggered: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
connectedStores: string[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client", "vitest/globals"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
recharts: ['recharts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
tailwindcss(),
|
||||||
|
VitePWA({
|
||||||
|
registerType: 'autoUpdate',
|
||||||
|
workbox: {
|
||||||
|
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
|
||||||
|
runtimeCaching: [
|
||||||
|
{
|
||||||
|
urlPattern: /^https?:\/\/.*\/api\/v1\/.*/i,
|
||||||
|
handler: 'StaleWhileRevalidate',
|
||||||
|
options: {
|
||||||
|
cacheName: 'api-cache',
|
||||||
|
expiration: {
|
||||||
|
maxEntries: 100,
|
||||||
|
maxAgeSeconds: 60 * 60 * 24,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
manifest: {
|
||||||
|
name: 'CartSnitch',
|
||||||
|
short_name: 'CartSnitch',
|
||||||
|
description: 'Track prices, find coupons, and optimize your grocery shopping.',
|
||||||
|
theme_color: '#1e40af',
|
||||||
|
background_color: '#ffffff',
|
||||||
|
display: 'standalone',
|
||||||
|
start_url: '/',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: '/icons/icon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icons/icon-512.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
src: '/icons/icon-512-maskable.png',
|
||||||
|
sizes: '512x512',
|
||||||
|
type: 'image/png',
|
||||||
|
purpose: 'maskable',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user