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:
Frontend Frankie
2026-03-17 12:23:51 +00:00
parent 4e9c888e0f
commit 5fbf0f5c5c
41 changed files with 12516 additions and 0 deletions
+24
View File
@@ -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?
+73
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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
View File
@@ -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>
+9958
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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

+24
View File
@@ -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

+17
View File
@@ -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
View File
@@ -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>
)
}
+74
View File
@@ -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>
)
}
+51
View File
@@ -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')
})
})
})
+13
View File
@@ -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>
)
}
+25
View File
@@ -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>
)
}
+55
View File
@@ -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'),
})
}
+18
View File
@@ -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;
}
+36
View File
@@ -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' }),
}
+190
View File
@@ -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 },
]
+10
View File
@@ -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>,
)
+204
View File
@@ -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">
&#x2713;
</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>
)
}
+203
View File
@@ -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">&middot;</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>
)
}
+71
View File
@@ -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>
)
}
+178
View File
@@ -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">
&#x2713;
</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',
})}{' '}
&middot; {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>
)
}
+58
View File
@@ -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>
)
}
+88
View File
@@ -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>
)
}
+187
View File
@@ -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} &middot; {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>
)
}
+86
View File
@@ -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} &middot; {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>
)
}
+84
View File
@@ -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>
)
}
+83
View File
@@ -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>
)
}
+98
View File
@@ -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>
)
}
+134
View File
@@ -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>
)
}
+93
View File
@@ -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} &middot; {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>
)
}
+24
View File
@@ -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' },
),
)
+19
View File
@@ -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' },
),
)
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+65
View File
@@ -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[]
}
+28
View File
@@ -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"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -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"]
}
+66
View File
@@ -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',
},
],
},
}),
],
})
+11
View File
@@ -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'],
},
})