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:
@@ -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}`, {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
|
||||
@@ -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
@@ -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 “{search}”.</p>
|
||||
</div>
|
||||
) : (
|
||||
filtered.map((product) => {
|
||||
products.map((product) => {
|
||||
const low = lowestPrice(product)
|
||||
const cheapest = product.prices.find((p) => p.price === low)
|
||||
return (
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user