forked from cartsnitch/cartsnitch
Merge pull request #2 from cartsnitch/feature/core-screens
feat: core PWA screens (auth, dashboard, purchases, products, alerts, settings)
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 |
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 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()
|
||||
})
|
||||
})
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { Layout } from './components/Layout.tsx'
|
||||
import { ProtectedRoute } from './components/ProtectedRoute.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 element={<ProtectedRoute />}>
|
||||
<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>
|
||||
<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 pb-[env(safe-area-inset-bottom)]">
|
||||
<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,12 @@
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth.ts'
|
||||
|
||||
export function ProtectedRoute() {
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />
|
||||
}
|
||||
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { LineChart, Line, ResponsiveContainer } from 'recharts'
|
||||
|
||||
export 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,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;
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
import { useAuthStore } from '../stores/auth.ts'
|
||||
import {
|
||||
mockPurchases,
|
||||
mockProducts,
|
||||
mockCoupons,
|
||||
mockAlerts,
|
||||
getMockPriceHistory,
|
||||
} from './mock-data.ts'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
|
||||
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
|
||||
|
||||
// Mock response lookup table
|
||||
const mockRoutes: Record<string, (path: string) => unknown> = {
|
||||
'/purchases': () => mockPurchases,
|
||||
'/products': () => mockProducts,
|
||||
'/coupons': () => mockCoupons,
|
||||
'/price-alerts': () => mockAlerts,
|
||||
}
|
||||
|
||||
function matchMockRoute<T>(path: string): T | null {
|
||||
// Exact match
|
||||
if (mockRoutes[path]) return mockRoutes[path](path) as T
|
||||
|
||||
// /purchases/:id
|
||||
const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
|
||||
if (purchaseMatch) {
|
||||
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
|
||||
return (purchase ?? null) as T
|
||||
}
|
||||
|
||||
// /products/:id/price-history
|
||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
||||
if (priceHistoryMatch) {
|
||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||
}
|
||||
|
||||
// /products?q=search or /products/:id
|
||||
const productMatch = path.match(/^\/products\/(.+)$/)
|
||||
if (productMatch) {
|
||||
const product = mockProducts.find((p) => p.id === productMatch[1])
|
||||
return (product ?? null) as T
|
||||
}
|
||||
|
||||
const productsSearch = path.match(/^\/products\?q=(.+)$/)
|
||||
if (productsSearch) {
|
||||
const q = decodeURIComponent(productsSearch[1]).toLowerCase()
|
||||
return mockProducts.filter(
|
||||
(p) =>
|
||||
p.name.toLowerCase().includes(q) ||
|
||||
p.brand.toLowerCase().includes(q) ||
|
||||
p.category.toLowerCase().includes(q),
|
||||
) as T
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
// Mock interceptor: return mock data without hitting the network
|
||||
if (USE_MOCK && (!options?.method || options.method === 'GET')) {
|
||||
const mockResult = matchMockRoute<T>(path)
|
||||
if (mockResult !== null) {
|
||||
// Simulate network delay for realistic loading states
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
return mockResult
|
||||
}
|
||||
}
|
||||
|
||||
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,210 @@
|
||||
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 — fields will be sent to API when available
|
||||
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-12 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
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||
Object.fromEntries(store.fields.map((f) => [f.key, ''])),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="mt-3 space-y-3">
|
||||
{store.fields.map((field) => (
|
||||
<input
|
||||
key={field.key}
|
||||
type={field.type}
|
||||
placeholder={field.label}
|
||||
value={values[field.key] ?? ''}
|
||||
onChange={(e) => setValues((prev) => ({ ...prev, [field.key]: e.target.value }))}
|
||||
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,233 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePriceAlerts } from '../hooks/useApi.ts'
|
||||
import type { PriceAlert } from '../types/api.ts'
|
||||
|
||||
export function Alerts() {
|
||||
const { data: fetchedAlerts = [], isLoading, error } = usePriceAlerts()
|
||||
const [localAlerts, setLocalAlerts] = useState<PriceAlert[]>([])
|
||||
const [deletedIds, setDeletedIds] = useState<Set<string>>(new Set())
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
|
||||
// Merge fetched + locally created, minus deleted
|
||||
const alerts = [
|
||||
...localAlerts,
|
||||
...fetchedAlerts.filter((a) => !deletedIds.has(a.id)),
|
||||
]
|
||||
|
||||
const triggered = alerts.filter((a) => a.triggered)
|
||||
const watching = alerts.filter((a) => !a.triggered)
|
||||
|
||||
function handleDelete(id: string) {
|
||||
setLocalAlerts((prev) => prev.filter((a) => a.id !== id))
|
||||
setDeletedIds((prev) => new Set(prev).add(id))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 w-32 rounded bg-gray-200" />
|
||||
<div className="mt-6 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load price alerts.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-12 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) => {
|
||||
setLocalAlerts((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-12 min-w-12 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,98 @@
|
||||
import { useState } from 'react'
|
||||
import { useCoupons } from '../hooks/useApi.ts'
|
||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||
|
||||
const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000
|
||||
|
||||
function isExpiringSoon(expiresAt: string): boolean {
|
||||
return new Date(expiresAt).getTime() - Date.now() < SEVEN_DAYS_MS
|
||||
}
|
||||
|
||||
export function Coupons() {
|
||||
const { data: coupons = [], isLoading, error } = useCoupons()
|
||||
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',
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load coupons.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Coupons & Deals</h1>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
{coupons.map((coupon) => {
|
||||
const expiringSoon = isExpiringSoon(coupon.expiresAt)
|
||||
|
||||
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 ${
|
||||
expiringSoon ? 'font-medium text-orange-600' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
Expires{' '}
|
||||
{new Date(coupon.expiresAt).toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})}
|
||||
{expiringSoon && ' — 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-12 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,197 @@
|
||||
import React, { Suspense } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useAuthStore } from '../stores/auth.ts'
|
||||
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
|
||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||
|
||||
const LazySparklineCard = React.lazy(() =>
|
||||
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
|
||||
)
|
||||
|
||||
export function Dashboard() {
|
||||
const user = useAuthStore((s) => s.user)
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
|
||||
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 <AuthenticatedDashboard userName={user?.name ?? 'there'} />
|
||||
}
|
||||
|
||||
function AuthenticatedDashboard({ userName }: { userName: string }) {
|
||||
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
|
||||
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
|
||||
const { data: eggHistory = [] } = usePriceHistory('prod10')
|
||||
const { data: milkHistory = [] } = usePriceHistory('prod1')
|
||||
|
||||
const triggeredAlerts = alerts.filter((a) => a.triggered)
|
||||
const watchingAlerts = alerts.filter((a) => !a.triggered)
|
||||
const recentPurchases = purchases.slice(0, 3)
|
||||
|
||||
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
|
||||
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
|
||||
|
||||
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
|
||||
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
|
||||
|
||||
if (purchasesLoading || alertsLoading) {
|
||||
return <DashboardSkeleton />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
Hi, {userName.split(' ')[0]}
|
||||
</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">
|
||||
<Suspense fallback={<SparklinePlaceholder />}>
|
||||
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
|
||||
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
|
||||
</Suspense>
|
||||
</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 DashboardSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 w-40 rounded bg-gray-200" />
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
<div className="h-24 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
<div className="mt-6 h-5 w-28 rounded bg-gray-200" />
|
||||
<div className="mt-3 space-y-3">
|
||||
<div className="h-16 rounded-xl bg-gray-200" />
|
||||
<div className="h-16 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SparklinePlaceholder() {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
<div className="mt-2 h-6 w-16 rounded bg-gray-200" />
|
||||
</div>
|
||||
<div className="h-10 w-24 rounded bg-gray-100" />
|
||||
</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,92 @@
|
||||
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 {
|
||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||
// Fallback to mock auth for demo
|
||||
setAuth(mockUser, 'mock-jwt-token')
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('Invalid email or password. Please try again.')
|
||||
}
|
||||
} 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,197 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
Tooltip,
|
||||
Legend,
|
||||
ResponsiveContainer,
|
||||
} from 'recharts'
|
||||
import { useProduct, usePriceHistory } from '../hooks/useApi.ts'
|
||||
|
||||
const storeLineColors: Record<string, string> = {
|
||||
meijer: '#e31837',
|
||||
kroger: '#0068a8',
|
||||
target: '#cc0000',
|
||||
}
|
||||
|
||||
export function ProductDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: product, isLoading: productLoading } = useProduct(id ?? '')
|
||||
const { data: history = [], isLoading: historyLoading } = usePriceHistory(id ?? '')
|
||||
|
||||
if (productLoading || historyLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
<div className="mt-4 h-8 w-48 rounded bg-gray-200" />
|
||||
<div className="mt-6 h-52 rounded-xl bg-gray-200" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 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,88 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useProducts } from '../hooks/useApi.ts'
|
||||
|
||||
export function Products() {
|
||||
const [search, setSearch] = useState('')
|
||||
const { data: products = [], isLoading, error } = useProducts(search || undefined)
|
||||
|
||||
const lowestPrice = (product: typeof products[0]) =>
|
||||
Math.min(...product.prices.map((p) => p.price))
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load products.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
{isLoading ? (
|
||||
[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 animate-pulse rounded-xl bg-gray-200" />
|
||||
))
|
||||
) : products.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>
|
||||
) : (
|
||||
products.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,98 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { usePurchase } from '../hooks/useApi.ts'
|
||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||
|
||||
export function PurchaseDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const { data: purchase, isLoading, error } = usePurchase(id ?? '')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-24 rounded bg-gray-200" />
|
||||
<div className="mt-4 h-20 rounded-xl bg-gray-200" />
|
||||
<div className="mt-4 space-y-1">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-12 rounded bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !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,113 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { usePurchases } from '../hooks/useApi.ts'
|
||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||
|
||||
export function Purchases() {
|
||||
const { data: purchases = [], isLoading, error } = usePurchases()
|
||||
const [storeFilter, setStoreFilter] = useState('all')
|
||||
|
||||
const stores = useMemo(
|
||||
() => ['all', ...new Set(purchases.map((p) => p.storeName))],
|
||||
[purchases],
|
||||
)
|
||||
|
||||
const filtered =
|
||||
storeFilter === 'all'
|
||||
? purchases
|
||||
: purchases.filter((p) => p.storeName === storeFilter)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-8 w-48 rounded bg-gray-200" />
|
||||
<div className="mt-4 flex gap-2">
|
||||
<div className="h-10 w-24 rounded-full bg-gray-200" />
|
||||
<div className="h-10 w-20 rounded-full bg-gray-200" />
|
||||
<div className="h-10 w-20 rounded-full bg-gray-200" />
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-24 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-red-600">Failed to load purchases.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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-12 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,102 @@
|
||||
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 {
|
||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||
// Fallback to mock auth for demo
|
||||
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
|
||||
navigate('/')
|
||||
} else {
|
||||
setError('Registration failed. Please try again.')
|
||||
}
|
||||
} 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-12 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,107 @@
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { useProduct } from '../hooks/useApi.ts'
|
||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||
|
||||
export function StoreComparison() {
|
||||
const { productId } = useParams<{ productId: string }>()
|
||||
const { data: product, isLoading } = useProduct(productId ?? '')
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="animate-pulse">
|
||||
<div className="h-4 w-20 rounded bg-gray-200" />
|
||||
<div className="mt-4 h-8 w-48 rounded bg-gray-200" />
|
||||
<div className="mt-4 space-y-3">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="h-20 rounded-xl bg-gray-200" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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,27 @@
|
||||
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',
|
||||
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
|
||||
},
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist } from 'zustand/middleware'
|
||||
|
||||
type Theme = 'light' | 'dark' | 'system'
|
||||
|
||||
interface ThemeState {
|
||||
theme: Theme
|
||||
setTheme: (theme: Theme) => void
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
if (theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark')
|
||||
} else {
|
||||
root.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
theme: 'system',
|
||||
setTheme: (theme) => {
|
||||
applyTheme(theme)
|
||||
set({ theme })
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'cartsnitch-theme',
|
||||
onRehydrateStorage: () => (state) => {
|
||||
if (state) applyTheme(state.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