fix: address Chip's review — secure auth, wire TanStack Query, fix UX issues

Must-fix:
- Exclude JWT token from Zustand persist (partialize) to prevent
  localStorage XSS exfiltration — token now lives in memory only
- Wire all pages through TanStack Query hooks (usePurchases, useProduct,
  useProducts, usePriceHistory, useCoupons, usePriceAlerts) with proper
  loading skeletons and error states
- Add mock interceptor in api.ts (VITE_MOCK_API=true) so mock data flows
  through the same fetch path — single flag to switch to live API

Should-fix:
- Wire theme toggle to DOM (dark class on <html>)
- Fix AccountLinking form inputs (controlled with value/onChange)
- Remove unused err in catch blocks (Login, Register)
- Bump remaining min-h-10 touch targets to min-h-12 (48px)

Build: 128KB initial JS, Recharts 498KB lazy chunk. 5/5 tests pass.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frontend Frankie
2026-03-18 11:54:06 +00:00
parent 034f12d0aa
commit fe5b0e87bd
14 changed files with 307 additions and 62 deletions
+64
View File
@@ -1,8 +1,72 @@
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}`, {
+12 -6
View File
@@ -44,9 +44,9 @@ export function AccountLinking() {
const [connected, setConnected] = useState<string[]>(['meijer', 'kroger'])
const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle')
function handleConnect(storeId: string) {
function handleConnect(storeId: string, _fields: Record<string, string>) {
setStatus('connecting')
// Simulate connection
// Simulate connection — fields will be sent to API when available
setTimeout(() => {
setConnected((prev) => [...prev, storeId])
setStatus('success')
@@ -100,7 +100,7 @@ export function AccountLinking() {
{isConnected && !isLinking && (
<button
onClick={() => handleDisconnect(store.id)}
className="mt-3 min-h-10 w-full rounded-xl border border-red-200 px-4 py-2 text-sm font-medium text-red-600 active:bg-red-50"
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>
@@ -119,7 +119,7 @@ export function AccountLinking() {
<LinkForm
store={store}
status={status}
onSubmit={() => handleConnect(store.id)}
onSubmit={(fields) => handleConnect(store.id, fields)}
onCancel={() => {
setLinking(null)
setStatus('idle')
@@ -149,9 +149,13 @@ function LinkForm({
}: {
store: StoreConfig
status: string
onSubmit: () => void
onSubmit: (fields: Record<string, string>) => 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) => (
@@ -159,6 +163,8 @@ function LinkForm({
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"
/>
@@ -186,7 +192,7 @@ function LinkForm({
{status === 'idle' && (
<div className="flex gap-3">
<button
onClick={onSubmit}
onClick={() => onSubmit(values)}
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
+35 -5
View File
@@ -1,17 +1,47 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { mockAlerts } from '../lib/mock-data.ts'
import { usePriceAlerts } from '../hooks/useApi.ts'
import type { PriceAlert } from '../types/api.ts'
export function Alerts() {
const [alerts, setAlerts] = useState<PriceAlert[]>(mockAlerts)
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) {
setAlerts((prev) => prev.filter((a) => a.id !== id))
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 (
@@ -28,7 +58,7 @@ export function Alerts() {
{/* Create alert form */}
{showCreate && <CreateAlertForm onClose={() => setShowCreate(false)} onCreated={(a) => {
setAlerts((prev) => [a, ...prev])
setLocalAlerts((prev) => [a, ...prev])
setShowCreate(false)
}} />}
@@ -115,7 +145,7 @@ function AlertCard({
)}
<button
onClick={() => onDelete(alert.id)}
className="min-h-10 min-w-10 rounded-lg p-2 text-gray-400 active:bg-gray-100"
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}>
+25 -3
View File
@@ -1,8 +1,9 @@
import { useState } from 'react'
import { mockCoupons } from '../lib/mock-data.ts'
import { useCoupons } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function Coupons() {
const { data: coupons = [], isLoading, error } = useCoupons()
const [copied, setCopied] = useState<string | null>(null)
function handleCopy(code: string, id: string) {
@@ -17,12 +18,33 @@ export function Coupons() {
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">
{mockCoupons.map((coupon) => {
{coupons.map((coupon) => {
const isExpiringSoon =
new Date(coupon.expiresAt).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
@@ -54,7 +76,7 @@ export function Coupons() {
{coupon.code && (
<button
onClick={() => handleCopy(coupon.code!, coupon.id)}
className="mt-3 flex min-h-10 w-full items-center justify-center gap-2 rounded-lg border border-dashed border-gray-300 px-4 py-2 text-sm font-mono active:bg-gray-50"
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">
+44 -11
View File
@@ -1,24 +1,17 @@
import React, { Suspense } from 'react'
import { Link } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { mockPurchases, mockAlerts, getMockPriceHistory } from '../lib/mock-data.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 }))
)
const sparklineData = getMockPriceHistory('prod10').filter((p) => p.storeId === 'meijer').slice(-8)
const milkSparkline = getMockPriceHistory('prod1').filter((p) => p.storeId === 'kroger').slice(-8)
export function Dashboard() {
const user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const triggeredAlerts = mockAlerts.filter((a) => a.triggered)
const watchingAlerts = mockAlerts.filter((a) => !a.triggered)
const recentPurchases = mockPurchases.slice(0, 3)
if (!isAuthenticated) {
return (
<div className="py-8 text-center">
@@ -42,10 +35,33 @@ export function Dashboard() {
)
}
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, {user?.name?.split(' ')[0] ?? 'there'}
Hi, {userName.split(' ')[0]}
</h1>
{/* Triggered alerts banner */}
@@ -89,8 +105,8 @@ export function Dashboard() {
<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="$5.44" />
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current="$3.29" />
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
</Suspense>
</div>
</section>
@@ -151,6 +167,23 @@ export function Dashboard() {
)
}
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">
+1 -1
View File
@@ -27,7 +27,7 @@ export function Login() {
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
setAuth(res.user, res.token)
navigate('/')
} catch (err) {
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo
setAuth(mockUser, 'mock-jwt-token')
+13 -3
View File
@@ -8,7 +8,7 @@ import {
Legend,
ResponsiveContainer,
} from 'recharts'
import { mockProducts, getMockPriceHistory } from '../lib/mock-data.ts'
import { useProduct, usePriceHistory } from '../hooks/useApi.ts'
const storeLineColors: Record<string, string> = {
meijer: '#e31837',
@@ -18,7 +18,18 @@ const storeLineColors: Record<string, string> = {
export function ProductDetail() {
const { id } = useParams<{ id: string }>()
const product = mockProducts.find((p) => p.id === id)
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 (
@@ -31,7 +42,6 @@ export function ProductDetail() {
)
}
const history = getMockPriceHistory(product.id)
const lowestPrice = Math.min(...product.prices.map((p) => p.price))
// Reshape history for chart: { date, meijer, kroger, target }
+19 -17
View File
@@ -1,24 +1,22 @@
import { useState, useMemo } from 'react'
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { mockProducts } from '../lib/mock-data.ts'
import { useProducts } from '../hooks/useApi.ts'
export function Products() {
const [search, setSearch] = useState('')
const { data: products = [], isLoading, error } = useProducts(search || undefined)
const filtered = useMemo(() => {
if (!search.trim()) return mockProducts
const q = search.toLowerCase()
return mockProducts.filter(
(p) =>
p.name.toLowerCase().includes(q) ||
p.brand.toLowerCase().includes(q) ||
p.category.toLowerCase().includes(q),
)
}, [search])
const lowestPrice = (product: typeof mockProducts[0]) =>
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>
@@ -36,12 +34,16 @@ export function Products() {
{/* Product list */}
<div className="mt-4 space-y-3">
{filtered.length === 0 ? (
{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>
<p className="text-sm text-gray-500">No products match &ldquo;{search}&rdquo;.</p>
</div>
) : (
filtered.map((product) => {
products.map((product) => {
const low = lowestPrice(product)
const cheapest = product.prices.find((p) => p.price === low)
return (
+17 -3
View File
@@ -1,12 +1,26 @@
import { useParams, Link } from 'react-router-dom'
import { mockPurchases } from '../lib/mock-data.ts'
import { usePurchase } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function PurchaseDetail() {
const { id } = useParams<{ id: string }>()
const purchase = mockPurchases.find((p) => p.id === id)
const { data: purchase, isLoading, error } = usePurchase(id ?? '')
if (!purchase) {
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>
+37 -7
View File
@@ -1,17 +1,47 @@
import { useState } from 'react'
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { mockPurchases } from '../lib/mock-data.ts'
import { usePurchases } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
const stores = ['all', ...new Set(mockPurchases.map((p) => p.storeName))]
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'
? mockPurchases
: mockPurchases.filter((p) => p.storeName === storeFilter)
? 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>
@@ -23,7 +53,7 @@ export function Purchases() {
<button
key={store}
onClick={() => setStoreFilter(store)}
className={`min-h-10 shrink-0 rounded-full px-4 text-sm font-medium ${
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'
+1 -1
View File
@@ -33,7 +33,7 @@ export function Register() {
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
setAuth(res.user, res.token)
navigate('/')
} catch (err) {
} catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
// Fallback to mock auth for demo
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
+16 -2
View File
@@ -1,10 +1,24 @@
import { useParams, Link } from 'react-router-dom'
import { mockProducts } from '../lib/mock-data.ts'
import { useProduct } from '../hooks/useApi.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function StoreComparison() {
const { productId } = useParams<{ productId: string }>()
const product = mockProducts.find((p) => p.id === productId)
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 (
+4 -1
View File
@@ -19,6 +19,9 @@ export const useAuthStore = create<AuthState>()(
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
logout: () => set({ user: null, token: null, isAuthenticated: false }),
}),
{ name: 'cartsnitch-auth' },
{
name: 'cartsnitch-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
},
),
)
+19 -2
View File
@@ -8,12 +8,29 @@ interface ThemeState {
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) => set({ theme }),
setTheme: (theme) => {
applyTheme(theme)
set({ theme })
},
}),
{ name: 'cartsnitch-theme' },
{
name: 'cartsnitch-theme',
onRehydrateStorage: () => (state) => {
if (state) applyTheme(state.theme)
},
},
),
)