Merge branch 'origin/main' into fix/auth-contract-mismatch

# Conflicts:
#	src/pages/Login.tsx
#	src/pages/Register.tsx
This commit is contained in:
Barcode Betty
2026-03-30 13:12:32 +00:00
14 changed files with 1068 additions and 780 deletions
+1
View File
@@ -11,6 +11,7 @@ node_modules
dist dist
dist-ssr dist-ssr
*.local *.local
.env
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
+7 -2
View File
@@ -12,6 +12,7 @@ CartSnitch is a self-hosted grocery price intelligence platform. This repo (`car
| Directory | Service | Purpose | | Directory | Service | Purpose |
|-----------|---------|---------| |-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) | | `/` (root) | Frontend | React PWA, mobile-first (this directory) |
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API | | `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations | | `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers | | `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
@@ -166,9 +167,13 @@ frontend/
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`. All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
- JWT auth: store access token in memory (not localStorage), refresh token in httpOnly cookie if possible, or secure storage. - **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
- API gateway validates sessions by querying the shared `sessions` table in Postgres
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- TanStack Query handles caching, background refetching, and optimistic updates. - TanStack Query handles caching, background refetching, and optimistic updates.
- API client should handle 401 responses by attempting token refresh before retrying. - API client sends `credentials: 'include'` on all requests to forward session cookies.
## Development Workflow ## Development Workflow
+1 -45
View File
@@ -1,45 +1 @@
# CartSnitch Monorepo # CartSnitch
CartSnitch is a self-hosted grocery price intelligence platform. This repo consolidates the core services and the flagship frontend PWA.
## Services
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | **Frontend** | React 18 PWA — mobile-first price intelligence UI |
| `api/` | **API Gateway** | FastAPI — frontend-facing REST API |
| `common/` | **Common** | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | **ReceiptWitness** | Purchase ingestion via retailer scrapers |
## Quick Start
### Frontend (root)
```bash
npm install
npm run dev # http://localhost:5173
npm run build # production build
npm run test # unit tests (Vitest)
```
### Python Services
Each Python service uses [uv](https://github.com/astral-sh/uv) and has its own `pyproject.toml`:
```bash
cd api # or common / receiptwitness
uv sync
uv run pytest
```
## Development Workflow
- **Never push directly to main.** Always open a PR from a feature branch.
- Branch naming: `feature/<description>` or `fix/<description>`
- Conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
## Architecture
For full details see [CLAUDE.md](./CLAUDE.md) or the per-service `CLAUDE.md` in each subdirectory.
CartSnitch is a polyrepo-style monorepo: each service can be built and deployed independently, but sharing code between `common/` and the other Python services is done via local path dependencies in `pyproject.toml`.
+469 -185
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
+7 -1
View File
@@ -1,7 +1,13 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx' import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({
authClient: {
useSession: () => ({ data: null, isPending: false }),
},
}))
describe('App', () => { describe('App', () => {
it('renders the dashboard on the root route', () => { it('renders the dashboard on the root route', () => {
render(<App />) render(<App />)
+17 -2
View File
@@ -1,10 +1,25 @@
import { useEffect } from 'react'
import { Navigate, Outlet } from 'react-router-dom' import { Navigate, Outlet } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() { export function ProtectedRoute() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const { data: session, isPending } = authClient.useSession()
const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
if (!isAuthenticated) { useEffect(() => {
setAuthenticated(!!session)
}, [session, setAuthenticated])
if (isPending) {
return (
<div className="flex min-h-screen items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
</div>
)
}
if (!session) {
return <Navigate to="/login" replace /> return <Navigate to="/login" replace />
} }
+98 -100
View File
@@ -1,100 +1,98 @@
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { import {
mockPurchases, mockPurchases,
mockProducts, mockProducts,
mockCoupons, mockCoupons,
mockAlerts, mockAlerts,
getMockPriceHistory, getMockPriceHistory,
} from './mock-data.ts' } from './mock-data.ts'
const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1' const API_BASE = import.meta.env.VITE_API_URL ?? '/api/v1'
const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true' const USE_MOCK = import.meta.env.VITE_MOCK_API === 'true'
// Mock response lookup table // Mock response lookup table
const mockRoutes: Record<string, (path: string) => unknown> = { const mockRoutes: Record<string, (path: string) => unknown> = {
'/purchases': () => mockPurchases, '/purchases': () => mockPurchases,
'/products': () => mockProducts, '/products': () => mockProducts,
'/coupons': () => mockCoupons, '/coupons': () => mockCoupons,
'/price-alerts': () => mockAlerts, '/price-alerts': () => mockAlerts,
} }
function matchMockRoute<T>(path: string): T | null { function matchMockRoute<T>(path: string): T | null {
// Exact match // Exact match
if (mockRoutes[path]) return mockRoutes[path](path) as T if (mockRoutes[path]) return mockRoutes[path](path) as T
// /purchases/:id // /purchases/:id
const purchaseMatch = path.match(/^\/purchases\/(.+)$/) const purchaseMatch = path.match(/^\/purchases\/(.+)$/)
if (purchaseMatch) { if (purchaseMatch) {
const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1]) const purchase = mockPurchases.find((p) => p.id === purchaseMatch[1])
return (purchase ?? null) as T return (purchase ?? null) as T
} }
// /products/:id/price-history // /products/:id/price-history
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
if (priceHistoryMatch) { if (priceHistoryMatch) {
return getMockPriceHistory(priceHistoryMatch[1]) as T return getMockPriceHistory(priceHistoryMatch[1]) as T
} }
// /products?q=search or /products/:id // /products/:id
const productMatch = path.match(/^\/products\/(.+)$/) const productMatch = path.match(/^\/products\/(.+)$/)
if (productMatch) { if (productMatch) {
const product = mockProducts.find((p) => p.id === productMatch[1]) const product = mockProducts.find((p) => p.id === productMatch[1])
return (product ?? null) as T return (product ?? null) as T
} }
const productsSearch = path.match(/^\/products\?q=(.+)$/) const productsSearch = path.match(/^\/products\?q=(.+)$/)
if (productsSearch) { if (productsSearch) {
const q = decodeURIComponent(productsSearch[1]).toLowerCase() const q = decodeURIComponent(productsSearch[1]).toLowerCase()
return mockProducts.filter( return mockProducts.filter(
(p) => (p) =>
p.name.toLowerCase().includes(q) || p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q), p.category.toLowerCase().includes(q),
) as T ) as T
} }
return null return null
} }
async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> { async function apiFetch<T>(path: string, options?: RequestInit): Promise<T> {
// Mock interceptor: return mock data without hitting the network // Mock interceptor: return mock data without hitting the network
if (USE_MOCK && (!options?.method || options.method === 'GET')) { if (USE_MOCK && (!options?.method || options.method === 'GET')) {
const mockResult = matchMockRoute<T>(path) const mockResult = matchMockRoute<T>(path)
if (mockResult !== null) { if (mockResult !== null) {
// Simulate network delay for realistic loading states // Simulate network delay for realistic loading states
await new Promise((r) => setTimeout(r, 300)) await new Promise((r) => setTimeout(r, 300))
return mockResult return mockResult
} }
} }
const token = useAuthStore.getState().token const res = await fetch(`${API_BASE}${path}`, {
...options,
const res = await fetch(`${API_BASE}${path}`, { credentials: 'include', // Send Better-Auth session cookie
...options, headers: {
headers: { 'Content-Type': 'application/json',
'Content-Type': 'application/json', ...options?.headers,
...(token ? { Authorization: `Bearer ${token}` } : {}), },
...options?.headers, })
},
}) if (res.status === 401) {
useAuthStore.getState().setAuthenticated(false)
if (res.status === 401) { throw new Error('Unauthorized')
useAuthStore.getState().logout() }
throw new Error('Unauthorized')
} if (!res.ok) {
throw new Error(`API error: ${res.status}`)
if (!res.ok) { }
throw new Error(`API error: ${res.status}`)
} return res.json() as Promise<T>
}
return res.json() as Promise<T>
} export const api = {
get: <T>(path: string) => apiFetch<T>(path),
export const api = { post: <T>(path: string, body: unknown) =>
get: <T>(path: string) => apiFetch<T>(path), apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }),
post: <T>(path: string, body: unknown) => put: <T>(path: string, body: unknown) =>
apiFetch<T>(path, { method: 'POST', body: JSON.stringify(body) }), apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }),
put: <T>(path: string, body: unknown) => delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
apiFetch<T>(path, { method: 'PUT', body: JSON.stringify(body) }), }
delete: <T>(path: string) => apiFetch<T>(path, { method: 'DELETE' }),
}
+36
View File
@@ -0,0 +1,36 @@
import { createAuthClient } from "better-auth/react"
import type { BetterFetchPlugin } from "@better-fetch/fetch"
/**
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
*/
const displayNameMapper: BetterFetchPlugin = {
id: "display-name-mapper",
name: "display-name-mapper",
hooks: {
onRequest: async (context) => {
const url = typeof context.url === "string" ? context.url : context.url.pathname
if (
url.endsWith("/auth/register") &&
context.method === "POST" &&
context.body &&
"name" in context.body
) {
context.body = {
...context.body,
display_name: context.body.name as string,
name: undefined,
}
}
return context
},
},
}
export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL ?? "http://localhost:3001",
basePath: "/auth",
fetchPlugins: [displayNameMapper],
})
export const { useSession, signIn, signUp, signOut } = authClient
+200 -197
View File
@@ -1,197 +1,200 @@
import React, { Suspense } from 'react' import React, { Suspense } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts' import { authClient } from '../lib/auth-client.ts'
import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts' import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
const LazySparklineCard = React.lazy(() => const LazySparklineCard = React.lazy(() =>
import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard })) import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard }))
) )
export function Dashboard() { export function Dashboard() {
const user = useAuthStore((s) => s.user) const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (isPending) {
if (!isAuthenticated) { return <DashboardSkeleton />
return ( }
<div className="py-8 text-center">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1> if (!session) {
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p> return (
<div className="mt-8 space-y-3"> <div className="py-8 text-center">
<Link <h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
to="/login" <p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
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" <div className="mt-8 space-y-3">
> <Link
Sign In to="/login"
</Link> 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"
<Link >
to="/register" Sign In
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" </Link>
> <Link
Create Account to="/register"
</Link> 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"
</div> >
</div> Create Account
) </Link>
} </div>
</div>
return <AuthenticatedDashboard userName={user?.name ?? 'there'} /> )
} }
function AuthenticatedDashboard({ userName }: { userName: string }) { return <AuthenticatedDashboard userName={session.user?.name ?? 'there'} />
const { data: purchases = [], isLoading: purchasesLoading } = usePurchases() }
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
const { data: eggHistory = [] } = usePriceHistory('prod10') function AuthenticatedDashboard({ userName }: { userName: string }) {
const { data: milkHistory = [] } = usePriceHistory('prod1') const { data: purchases = [], isLoading: purchasesLoading } = usePurchases()
const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts()
const triggeredAlerts = alerts.filter((a) => a.triggered) const { data: eggHistory = [] } = usePriceHistory('prod10')
const watchingAlerts = alerts.filter((a) => !a.triggered) const { data: milkHistory = [] } = usePriceHistory('prod1')
const recentPurchases = purchases.slice(0, 3)
const triggeredAlerts = alerts.filter((a) => a.triggered)
const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8) const watchingAlerts = alerts.filter((a) => !a.triggered)
const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8) const recentPurchases = purchases.slice(0, 3)
const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—' const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8)
const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—' const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8)
if (purchasesLoading || alertsLoading) { const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—'
return <DashboardSkeleton /> const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—'
}
if (purchasesLoading || alertsLoading) {
return ( return <DashboardSkeleton />
<div> }
<h1 className="text-2xl font-bold text-gray-900">
Hi, {userName.split(' ')[0]} return (
</h1> <div>
<h1 className="text-2xl font-bold text-gray-900">
{/* Triggered alerts banner */} Hi, {userName.split(' ')[0]}
{triggeredAlerts.length > 0 && ( </h1>
<Link
to="/alerts" {/* Triggered alerts banner */}
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4" {triggeredAlerts.length > 0 && (
> <Link
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white"> to="/alerts"
&#x2713; className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
</span> >
<div> <span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
<p className="text-sm font-semibold text-green-800"> &#x2713;
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered! </span>
</p> <div>
<p className="text-xs text-green-700"> <p className="text-sm font-semibold text-green-800">
{triggeredAlerts.map((a) => a.productName).join(', ')} {triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
</p> </p>
</div> <p className="text-xs text-green-700">
</Link> {triggeredAlerts.map((a) => a.productName).join(', ')}
)} </p>
</div>
{/* Quick stats */} </Link>
<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> {/* Quick stats */}
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p> <div className="mt-4 grid grid-cols-2 gap-3">
<p className="text-xs text-gray-400">price alerts</p> <div className="rounded-xl bg-white p-4 shadow-sm">
</div> <p className="text-xs font-medium text-gray-500">Watching</p>
<div className="rounded-xl bg-white p-4 shadow-sm"> <p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<p className="text-xs font-medium text-gray-500">This Month</p> <p className="text-xs text-gray-400">price alerts</p>
<p className="mt-1 text-2xl font-bold text-gray-900"> </div>
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)} <div className="rounded-xl bg-white p-4 shadow-sm">
</p> <p className="text-xs font-medium text-gray-500">This Month</p>
<p className="text-xs text-gray-400">grocery spend</p> <p className="mt-1 text-2xl font-bold text-gray-900">
</div> ${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
</div> </p>
<p className="text-xs text-gray-400">grocery spend</p>
{/* Price trend sparklines */} </div>
<section className="mt-6"> </div>
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<div className="space-y-3"> {/* Price trend sparklines */}
<Suspense fallback={<SparklinePlaceholder />}> <section className="mt-6">
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} /> <h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} /> <div className="space-y-3">
</Suspense> <Suspense fallback={<SparklinePlaceholder />}>
</div> <LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
</section> <LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
</Suspense>
{/* Recent purchases */} </div>
<section className="mt-6"> </section>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2> {/* Recent purchases */}
<Link to="/purchases" className="text-sm text-brand-blue"> <section className="mt-6">
View all <div className="flex items-center justify-between">
</Link> <h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
</div> <Link to="/purchases" className="text-sm text-brand-blue">
<div className="mt-3 space-y-3"> View all
{recentPurchases.map((purchase) => ( </Link>
<Link </div>
key={purchase.id} <div className="mt-3 space-y-3">
to={`/purchases/${purchase.id}`} {recentPurchases.map((purchase) => (
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50" <Link
> key={purchase.id}
<StoreIcon storeId={purchase.storeId} /> to={`/purchases/${purchase.id}`}
<div className="min-w-0 flex-1"> className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p> >
<p className="text-xs text-gray-500"> <StoreIcon storeId={purchase.storeId} />
{new Date(purchase.date).toLocaleDateString('en-US', { <div className="min-w-0 flex-1">
month: 'short', <p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
day: 'numeric', <p className="text-xs text-gray-500">
})}{' '} {new Date(purchase.date).toLocaleDateString('en-US', {
&middot; {purchase.items.length} items month: 'short',
</p> day: 'numeric',
</div> })}{' '}
<span className="text-sm font-semibold text-gray-900"> &middot; {purchase.items.length} items
${purchase.total.toFixed(2)} </p>
</span> </div>
</Link> <span className="text-sm font-semibold text-gray-900">
))} ${purchase.total.toFixed(2)}
</div> </span>
</section> </Link>
))}
{/* Quick actions */} </div>
<section className="mt-6 pb-4"> </section>
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
<div className="grid grid-cols-2 gap-3"> {/* Quick actions */}
<Link <section className="mt-6 pb-4">
to="/products" <h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
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" <div className="grid grid-cols-2 gap-3">
> <Link
Compare Prices to="/products"
</Link> 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 >
to="/settings" Compare Prices
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>
> <Link
Link a Store to="/settings"
</Link> 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"
</div> >
</section> Link a Store
</div> </Link>
) </div>
} </section>
</div>
function DashboardSkeleton() { )
return ( }
<div className="animate-pulse">
<div className="h-8 w-40 rounded bg-gray-200" /> function DashboardSkeleton() {
<div className="mt-4 grid grid-cols-2 gap-3"> return (
<div className="h-24 rounded-xl bg-gray-200" /> <div className="animate-pulse">
<div className="h-24 rounded-xl bg-gray-200" /> <div className="h-8 w-40 rounded bg-gray-200" />
</div> <div className="mt-4 grid grid-cols-2 gap-3">
<div className="mt-6 h-5 w-28 rounded bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
<div className="mt-3 space-y-3"> <div className="h-24 rounded-xl bg-gray-200" />
<div className="h-16 rounded-xl bg-gray-200" /> </div>
<div className="h-16 rounded-xl bg-gray-200" /> <div className="mt-6 h-5 w-28 rounded bg-gray-200" />
</div> <div className="mt-3 space-y-3">
</div> <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"> function SparklinePlaceholder() {
<div className="h-4 w-24 rounded bg-gray-200" /> return (
<div className="mt-2 h-6 w-16 rounded bg-gray-200" /> <div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
</div> <div className="min-w-0 flex-1">
<div className="h-10 w-24 rounded bg-gray-100" /> <div className="h-4 w-24 rounded bg-gray-200" />
</div> <div className="mt-2 h-6 w-16 rounded bg-gray-200" />
) </div>
} <div className="h-10 w-24 rounded bg-gray-100" />
</div>
)
}
+97 -103
View File
@@ -1,103 +1,97 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts' import { authClient } from '../lib/auth-client.ts'
import { api } from '../lib/api.ts' import { useAuthStore } from '../stores/auth.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts' export function Login() {
const [email, setEmail] = useState('')
interface TokenResponse { const [password, setPassword] = useState('')
access_token: string const [error, setError] = useState('')
refresh_token: string const [loading, setLoading] = useState(false)
token_type: string const navigate = useNavigate()
expires_in: number const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
}
async function handleSubmit(e: React.FormEvent) {
export function Login() { e.preventDefault()
const [email, setEmail] = useState('') setError('')
const [password, setPassword] = useState('')
const [error, setError] = useState('') if (!email || !password) {
const [loading, setLoading] = useState(false) setError('Please fill in all fields.')
const navigate = useNavigate() return
const setAuth = useAuthStore((s) => s.setAuth) }
async function handleSubmit(e: React.FormEvent) { setLoading(true)
e.preventDefault() try {
setError('') const { error: authError } = await authClient.signIn.email({
email,
if (!email || !password) { password,
setError('Please fill in all fields.') })
return
} if (authError) {
throw new Error(authError.message ?? 'Sign in failed')
setLoading(true) }
try {
const res = await api.post<TokenResponse>('/auth/login', { email, password }) setAuthenticated(true)
const userRes = await fetch(`${import.meta.env.VITE_API_URL ?? '/api/v1'}/auth/me`, { navigate('/')
headers: { Authorization: `Bearer ${res.access_token}` }, } catch {
}) if (import.meta.env.VITE_MOCK_AUTH === 'true') {
const user = (await userRes.json()) as User setAuthenticated(true)
setAuth(user, res.access_token) navigate('/')
navigate('/') } else {
} catch { setError('Invalid email or password. Please try again.')
if (import.meta.env.VITE_MOCK_AUTH === 'true') { }
// Fallback to mock auth for demo } finally {
setAuth(mockUser, 'mock-jwt-token') setLoading(false)
navigate('/') }
} else { }
setError('Invalid email or password. Please try again.')
} return (
} finally { <div className="flex min-h-screen flex-col items-center justify-center px-4">
setLoading(false) <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 && (
return ( <div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<div className="flex min-h-screen flex-col items-center justify-center px-4"> {error}
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1> </div>
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p> )}
{error && ( <form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700"> <input
{error} type="email"
</div> placeholder="Email"
)} value={email}
onChange={(e) => setEmail(e.target.value)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}> autoComplete="email"
<input 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"
type="email" />
placeholder="Email" <input
value={email} type="password"
onChange={(e) => setEmail(e.target.value)} placeholder="Password"
autoComplete="email" value={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" onChange={(e) => setPassword(e.target.value)}
/> autoComplete="current-password"
<input 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"
type="password" />
placeholder="Password" <button
value={password} type="submit"
onChange={(e) => setPassword(e.target.value)} disabled={loading}
autoComplete="current-password" 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"
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" >
/> {loading ? 'Signing in...' : 'Sign In'}
<button </button>
type="submit" </form>
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" <Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
> Forgot password?
{loading ? 'Signing in...' : 'Sign In'} </Link>
</button>
</form> <p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue"> <Link to="/register" className="text-brand-blue">
Forgot password? Sign up
</Link> </Link>
</p>
<p className="mt-6 text-sm text-gray-500"> </div>
Don't have an account?{' '} )
<Link to="/register" className="text-brand-blue"> }
Sign up
</Link>
</p>
</div>
)
}
+108 -113
View File
@@ -1,113 +1,108 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts' import { authClient } from '../lib/auth-client.ts'
import { api } from '../lib/api.ts' import { useAuthStore } from '../stores/auth.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts' export function Register() {
const [name, setName] = useState('')
interface TokenResponse { const [email, setEmail] = useState('')
access_token: string const [password, setPassword] = useState('')
refresh_token: string const [error, setError] = useState('')
token_type: string const [loading, setLoading] = useState(false)
expires_in: number const navigate = useNavigate()
} const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
export function Register() { async function handleSubmit(e: React.FormEvent) {
const [name, setName] = useState('') e.preventDefault()
const [email, setEmail] = useState('') setError('')
const [password, setPassword] = useState('')
const [error, setError] = useState('') if (!name || !email || !password) {
const [loading, setLoading] = useState(false) setError('Please fill in all fields.')
const navigate = useNavigate() return
const setAuth = useAuthStore((s) => s.setAuth) }
async function handleSubmit(e: React.FormEvent) { if (password.length < 8) {
e.preventDefault() setError('Password must be at least 8 characters.')
setError('') return
}
if (!name || !email || !password) {
setError('Please fill in all fields.') setLoading(true)
return try {
} const { error: authError } = await authClient.signUp.email({
name,
if (password.length < 8) { email,
setError('Password must be at least 8 characters.') password,
return })
}
if (authError) {
setLoading(true) throw new Error(authError.message ?? 'Registration failed')
try { }
const res = await api.post<TokenResponse>('/auth/register', { display_name: name, email, password })
const userRes = await fetch(`${import.meta.env.VITE_API_URL ?? '/api/v1'}/auth/me`, { setAuthenticated(true)
headers: { Authorization: `Bearer ${res.access_token}` }, navigate('/')
}) } catch {
const user = (await userRes.json()) as User if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuth(user, res.access_token) setAuthenticated(true)
navigate('/') navigate('/')
} catch { } else {
if (import.meta.env.VITE_MOCK_AUTH === 'true') { setError('Registration failed. Please try again.')
// Fallback to mock auth for demo }
setAuth({ ...mockUser, name, email }, 'mock-jwt-token') } finally {
navigate('/') setLoading(false)
} else { }
setError('Registration failed. Please try again.') }
}
} finally { return (
setLoading(false) <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>
return ( {error && (
<div className="flex min-h-screen flex-col items-center justify-center px-4"> <div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1> {error}
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p> </div>
)}
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700"> <form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
{error} <input
</div> type="text"
)} placeholder="Full Name"
value={name}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}> onChange={(e) => setName(e.target.value)}
<input autoComplete="name"
type="text" 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"
placeholder="Full Name" />
value={name} <input
onChange={(e) => setName(e.target.value)} type="email"
autoComplete="name" placeholder="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" value={email}
/> onChange={(e) => setEmail(e.target.value)}
<input autoComplete="email"
type="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"
placeholder="Email" />
value={email} <input
onChange={(e) => setEmail(e.target.value)} type="password"
autoComplete="email" placeholder="Password (min. 8 characters)"
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" value={password}
/> onChange={(e) => setPassword(e.target.value)}
<input autoComplete="new-password"
type="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"
placeholder="Password (min. 8 characters)" />
value={password} <button
onChange={(e) => setPassword(e.target.value)} type="submit"
autoComplete="new-password" disabled={loading}
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" 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"
/> >
<button {loading ? 'Creating account...' : 'Create Account'}
type="submit" </button>
disabled={loading} </form>
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"
> <p className="mt-6 text-sm text-gray-500">
{loading ? 'Creating account...' : 'Create Account'} Already have an account?{' '}
</button> <Link to="/login" className="text-brand-blue">
</form> Sign in
</Link>
<p className="mt-6 text-sm text-gray-500"> </p>
Already have an account?{' '} </div>
<Link to="/login" className="text-brand-blue"> )
Sign in }
</Link>
</p>
</div>
)
}
+8 -5
View File
@@ -1,18 +1,21 @@
import { Link, useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts' import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx' import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() { export function Settings() {
const user = useAuthStore((s) => s.user) const { data: session } = authClient.useSession()
const logout = useAuthStore((s) => s.logout) const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
const navigate = useNavigate() const navigate = useNavigate()
const { theme, setTheme } = useThemeStore() const { theme, setTheme } = useThemeStore()
const connectedStores = user?.connectedStores ?? [] const user = session?.user
const connectedStores: string[] = []
function handleSignOut() { async function handleSignOut() {
logout() await authClient.signOut()
setAuthenticated(false)
navigate('/login') navigate('/login')
} }
+18 -27
View File
@@ -1,27 +1,18 @@
import { create } from 'zustand' import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types/api.ts' /**
* Minimal auth state for UI reactivity.
interface AuthState { *
user: User | null * Session management is handled by Better-Auth via httpOnly cookies.
token: string | null * This store only tracks whether we have an active session for UI
isAuthenticated: boolean * gating (protected routes, nav state). No tokens in memory or localStorage.
setAuth: (user: User, token: string) => void */
logout: () => void interface AuthState {
} isAuthenticated: boolean
setAuthenticated: (value: boolean) => void
export const useAuthStore = create<AuthState>()( }
persist(
(set) => ({ export const useAuthStore = create<AuthState>()((set) => ({
user: null, isAuthenticated: false,
token: null, setAuthenticated: (value) => set({ isAuthenticated: value }),
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 }),
},
),
)