forked from cartsnitch/cartsnitch
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 { 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 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> {
|
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 token = useAuthStore.getState().token
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE}${path}`, {
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export function AccountLinking() {
|
|||||||
const [connected, setConnected] = useState<string[]>(['meijer', 'kroger'])
|
const [connected, setConnected] = useState<string[]>(['meijer', 'kroger'])
|
||||||
const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle')
|
const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle')
|
||||||
|
|
||||||
function handleConnect(storeId: string) {
|
function handleConnect(storeId: string, _fields: Record<string, string>) {
|
||||||
setStatus('connecting')
|
setStatus('connecting')
|
||||||
// Simulate connection
|
// Simulate connection — fields will be sent to API when available
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setConnected((prev) => [...prev, storeId])
|
setConnected((prev) => [...prev, storeId])
|
||||||
setStatus('success')
|
setStatus('success')
|
||||||
@@ -100,7 +100,7 @@ export function AccountLinking() {
|
|||||||
{isConnected && !isLinking && (
|
{isConnected && !isLinking && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDisconnect(store.id)}
|
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
|
Disconnect
|
||||||
</button>
|
</button>
|
||||||
@@ -119,7 +119,7 @@ export function AccountLinking() {
|
|||||||
<LinkForm
|
<LinkForm
|
||||||
store={store}
|
store={store}
|
||||||
status={status}
|
status={status}
|
||||||
onSubmit={() => handleConnect(store.id)}
|
onSubmit={(fields) => handleConnect(store.id, fields)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setLinking(null)
|
setLinking(null)
|
||||||
setStatus('idle')
|
setStatus('idle')
|
||||||
@@ -149,9 +149,13 @@ function LinkForm({
|
|||||||
}: {
|
}: {
|
||||||
store: StoreConfig
|
store: StoreConfig
|
||||||
status: string
|
status: string
|
||||||
onSubmit: () => void
|
onSubmit: (fields: Record<string, string>) => void
|
||||||
onCancel: () => void
|
onCancel: () => void
|
||||||
}) {
|
}) {
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(() =>
|
||||||
|
Object.fromEntries(store.fields.map((f) => [f.key, ''])),
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="mt-3 space-y-3">
|
||||||
{store.fields.map((field) => (
|
{store.fields.map((field) => (
|
||||||
@@ -159,6 +163,8 @@ function LinkForm({
|
|||||||
key={field.key}
|
key={field.key}
|
||||||
type={field.type}
|
type={field.type}
|
||||||
placeholder={field.label}
|
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}
|
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"
|
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' && (
|
{status === 'idle' && (
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<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"
|
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
|
Connect
|
||||||
|
|||||||
+35
-5
@@ -1,17 +1,47 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
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'
|
import type { PriceAlert } from '../types/api.ts'
|
||||||
|
|
||||||
export function Alerts() {
|
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)
|
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 triggered = alerts.filter((a) => a.triggered)
|
||||||
const watching = alerts.filter((a) => !a.triggered)
|
const watching = alerts.filter((a) => !a.triggered)
|
||||||
|
|
||||||
function handleDelete(id: string) {
|
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 (
|
return (
|
||||||
@@ -28,7 +58,7 @@ export function Alerts() {
|
|||||||
|
|
||||||
{/* Create alert form */}
|
{/* Create alert form */}
|
||||||
{showCreate && <CreateAlertForm onClose={() => setShowCreate(false)} onCreated={(a) => {
|
{showCreate && <CreateAlertForm onClose={() => setShowCreate(false)} onCreated={(a) => {
|
||||||
setAlerts((prev) => [a, ...prev])
|
setLocalAlerts((prev) => [a, ...prev])
|
||||||
setShowCreate(false)
|
setShowCreate(false)
|
||||||
}} />}
|
}} />}
|
||||||
|
|
||||||
@@ -115,7 +145,7 @@ function AlertCard({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => onDelete(alert.id)}
|
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"
|
aria-label="Delete alert"
|
||||||
>
|
>
|
||||||
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<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 { useState } from 'react'
|
||||||
import { mockCoupons } from '../lib/mock-data.ts'
|
import { useCoupons } from '../hooks/useApi.ts'
|
||||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
export function Coupons() {
|
export function Coupons() {
|
||||||
|
const { data: coupons = [], isLoading, error } = useCoupons()
|
||||||
const [copied, setCopied] = useState<string | null>(null)
|
const [copied, setCopied] = useState<string | null>(null)
|
||||||
|
|
||||||
function handleCopy(code: string, id: string) {
|
function handleCopy(code: string, id: string) {
|
||||||
@@ -17,12 +18,33 @@ export function Coupons() {
|
|||||||
Target: 'target',
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Coupons & Deals</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Coupons & Deals</h1>
|
||||||
|
|
||||||
<div className="mt-4 space-y-3">
|
<div className="mt-4 space-y-3">
|
||||||
{mockCoupons.map((coupon) => {
|
{coupons.map((coupon) => {
|
||||||
const isExpiringSoon =
|
const isExpiringSoon =
|
||||||
new Date(coupon.expiresAt).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
|
new Date(coupon.expiresAt).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
|
||||||
|
|
||||||
@@ -54,7 +76,7 @@ export function Coupons() {
|
|||||||
{coupon.code && (
|
{coupon.code && (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleCopy(coupon.code!, coupon.id)}
|
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-gray-700">{coupon.code}</span>
|
||||||
<span className="text-xs text-brand-blue">
|
<span className="text-xs text-brand-blue">
|
||||||
|
|||||||
+44
-11
@@ -1,24 +1,17 @@
|
|||||||
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 { 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'
|
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 }))
|
||||||
)
|
)
|
||||||
|
|
||||||
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() {
|
export function Dashboard() {
|
||||||
const user = useAuthStore((s) => s.user)
|
const user = useAuthStore((s) => s.user)
|
||||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
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) {
|
if (!isAuthenticated) {
|
||||||
return (
|
return (
|
||||||
<div className="py-8 text-center">
|
<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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
Hi, {user?.name?.split(' ')[0] ?? 'there'}
|
Hi, {userName.split(' ')[0]}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Triggered alerts banner */}
|
{/* Triggered alerts banner */}
|
||||||
@@ -89,8 +105,8 @@ export function Dashboard() {
|
|||||||
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
|
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Suspense fallback={<SparklinePlaceholder />}>
|
<Suspense fallback={<SparklinePlaceholder />}>
|
||||||
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current="$5.44" />
|
<LazySparklineCard label="Eggs (dozen)" data={sparklineData} current={eggCurrent} />
|
||||||
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current="$3.29" />
|
<LazySparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current={milkCurrent} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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() {
|
function SparklinePlaceholder() {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm animate-pulse">
|
<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 })
|
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
|
||||||
setAuth(res.user, res.token)
|
setAuth(res.user, res.token)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
// Fallback to mock auth for demo
|
// Fallback to mock auth for demo
|
||||||
setAuth(mockUser, 'mock-jwt-token')
|
setAuth(mockUser, 'mock-jwt-token')
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
Legend,
|
Legend,
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
} from 'recharts'
|
} from 'recharts'
|
||||||
import { mockProducts, getMockPriceHistory } from '../lib/mock-data.ts'
|
import { useProduct, usePriceHistory } from '../hooks/useApi.ts'
|
||||||
|
|
||||||
const storeLineColors: Record<string, string> = {
|
const storeLineColors: Record<string, string> = {
|
||||||
meijer: '#e31837',
|
meijer: '#e31837',
|
||||||
@@ -18,7 +18,18 @@ const storeLineColors: Record<string, string> = {
|
|||||||
|
|
||||||
export function ProductDetail() {
|
export function ProductDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
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) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
@@ -31,7 +42,6 @@ export function ProductDetail() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const history = getMockPriceHistory(product.id)
|
|
||||||
const lowestPrice = Math.min(...product.prices.map((p) => p.price))
|
const lowestPrice = Math.min(...product.prices.map((p) => p.price))
|
||||||
|
|
||||||
// Reshape history for chart: { date, meijer, kroger, target }
|
// 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 { Link } from 'react-router-dom'
|
||||||
import { mockProducts } from '../lib/mock-data.ts'
|
import { useProducts } from '../hooks/useApi.ts'
|
||||||
|
|
||||||
export function Products() {
|
export function Products() {
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
|
const { data: products = [], isLoading, error } = useProducts(search || undefined)
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const lowestPrice = (product: typeof products[0]) =>
|
||||||
if (!search.trim()) return mockProducts
|
|
||||||
const q = search.toLowerCase()
|
|
||||||
return mockProducts.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(q) ||
|
|
||||||
p.brand.toLowerCase().includes(q) ||
|
|
||||||
p.category.toLowerCase().includes(q),
|
|
||||||
)
|
|
||||||
}, [search])
|
|
||||||
|
|
||||||
const lowestPrice = (product: typeof mockProducts[0]) =>
|
|
||||||
Math.min(...product.prices.map((p) => p.price))
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
|
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
|
||||||
@@ -36,12 +34,16 @@ export function Products() {
|
|||||||
|
|
||||||
{/* Product list */}
|
{/* Product list */}
|
||||||
<div className="mt-4 space-y-3">
|
<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">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((product) => {
|
products.map((product) => {
|
||||||
const low = lowestPrice(product)
|
const low = lowestPrice(product)
|
||||||
const cheapest = product.prices.find((p) => p.price === low)
|
const cheapest = product.prices.find((p) => p.price === low)
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
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'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
export function PurchaseDetail() {
|
export function PurchaseDetail() {
|
||||||
const { id } = useParams<{ id: string }>()
|
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 (
|
return (
|
||||||
<div className="py-8 text-center">
|
<div className="py-8 text-center">
|
||||||
<p className="text-sm text-gray-500">Purchase not found.</p>
|
<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 { Link } from 'react-router-dom'
|
||||||
import { mockPurchases } from '../lib/mock-data.ts'
|
import { usePurchases } from '../hooks/useApi.ts'
|
||||||
import { StoreIcon } from '../components/StoreIcon.tsx'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
const stores = ['all', ...new Set(mockPurchases.map((p) => p.storeName))]
|
|
||||||
|
|
||||||
export function Purchases() {
|
export function Purchases() {
|
||||||
|
const { data: purchases = [], isLoading, error } = usePurchases()
|
||||||
const [storeFilter, setStoreFilter] = useState('all')
|
const [storeFilter, setStoreFilter] = useState('all')
|
||||||
|
|
||||||
|
const stores = useMemo(
|
||||||
|
() => ['all', ...new Set(purchases.map((p) => p.storeName))],
|
||||||
|
[purchases],
|
||||||
|
)
|
||||||
|
|
||||||
const filtered =
|
const filtered =
|
||||||
storeFilter === 'all'
|
storeFilter === 'all'
|
||||||
? mockPurchases
|
? purchases
|
||||||
: mockPurchases.filter((p) => p.storeName === storeFilter)
|
: 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -23,7 +53,7 @@ export function Purchases() {
|
|||||||
<button
|
<button
|
||||||
key={store}
|
key={store}
|
||||||
onClick={() => setStoreFilter(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
|
storeFilter === store
|
||||||
? 'bg-brand-blue text-white'
|
? 'bg-brand-blue text-white'
|
||||||
: 'bg-white text-gray-700 shadow-sm'
|
: '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 })
|
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
|
||||||
setAuth(res.user, res.token)
|
setAuth(res.user, res.token)
|
||||||
navigate('/')
|
navigate('/')
|
||||||
} catch (err) {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
// Fallback to mock auth for demo
|
// Fallback to mock auth for demo
|
||||||
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
|
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { useParams, Link } from 'react-router-dom'
|
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'
|
import { StoreIcon } from '../components/StoreIcon.tsx'
|
||||||
|
|
||||||
export function StoreComparison() {
|
export function StoreComparison() {
|
||||||
const { productId } = useParams<{ productId: string }>()
|
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) {
|
if (!product) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+4
-1
@@ -19,6 +19,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
setAuth: (user, token) => set({ user, token, isAuthenticated: true }),
|
||||||
logout: () => set({ user: null, token: null, isAuthenticated: false }),
|
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
|
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>()(
|
export const useThemeStore = create<ThemeState>()(
|
||||||
persist(
|
persist(
|
||||||
(set) => ({
|
(set) => ({
|
||||||
theme: 'system',
|
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