feat: add core PWA screens (auth, dashboard, purchases, products, alerts, settings)

Build all 8 primary screens for CAR-33 on top of the Phase 1 scaffold:
- Auth: login, register, forgot password with JWT flow and mock fallback
- Dashboard: triggered alerts banner, spending stats, price trend sparklines (Recharts), recent purchases
- Purchase History: store filter chips, paginated list with item previews
- Purchase Detail: receipt view with line items linking to product pages
- Products: search with instant filter, store price comparison badges
- Product Detail: 90-day price history chart (Recharts), store comparison table
- Store Comparison: ranked store cards with savings banner
- Price Alerts: triggered/watching sections, create form, progress bars, delete
- Coupons: expiration warnings, copy-to-clipboard coupon codes
- Account Linking: connect Meijer/Kroger/Target with status indicators
- Settings: profile, connected stores, notification toggles, theme switcher, sign out

Also adds:
- Mock data layer (src/lib/mock-data.ts) for demo/screenshot use
- StoreIcon component with store brand colors
- Code-split Recharts chunk (initial JS: 117KB, Recharts lazy: 498KB)
- All 48px+ touch targets, mobile-first Tailwind layout

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Frontend Frankie
2026-03-17 12:23:51 +00:00
parent 4e9c888e0f
commit 5fbf0f5c5c
41 changed files with 12516 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { StoreIcon } from '../components/StoreIcon.tsx'
interface StoreConfig {
id: string
name: string
description: string
fields: { key: string; label: string; type: string }[]
}
const availableStores: StoreConfig[] = [
{
id: 'meijer',
name: 'Meijer',
description: 'Connect your mPerks account to import purchase history.',
fields: [
{ key: 'email', label: 'mPerks Email', type: 'email' },
{ key: 'password', label: 'mPerks Password', type: 'password' },
],
},
{
id: 'kroger',
name: 'Kroger',
description: 'Connect your Kroger Plus account for receipts and digital coupons.',
fields: [
{ key: 'email', label: 'Kroger Email', type: 'email' },
{ key: 'password', label: 'Kroger Password', type: 'password' },
],
},
{
id: 'target',
name: 'Target',
description: 'Connect Target Circle for purchase history and deals.',
fields: [
{ key: 'email', label: 'Target Email', type: 'email' },
{ key: 'password', label: 'Target Password', type: 'password' },
],
},
]
export function AccountLinking() {
const [linking, setLinking] = useState<string | null>(null)
const [connected, setConnected] = useState<string[]>(['meijer', 'kroger'])
const [status, setStatus] = useState<'idle' | 'connecting' | 'success' | 'error'>('idle')
function handleConnect(storeId: string) {
setStatus('connecting')
// Simulate connection
setTimeout(() => {
setConnected((prev) => [...prev, storeId])
setStatus('success')
setTimeout(() => {
setLinking(null)
setStatus('idle')
}, 1500)
}, 2000)
}
function handleDisconnect(storeId: string) {
setConnected((prev) => prev.filter((s) => s !== storeId))
}
return (
<div>
<Link to="/settings" className="inline-flex items-center gap-1 text-sm text-brand-blue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Settings
</Link>
<h1 className="mt-4 text-2xl font-bold text-gray-900">Connect a Store</h1>
<p className="mt-1 text-sm text-gray-500">
Link your store loyalty accounts to automatically import purchases and track prices.
</p>
<div className="mt-6 space-y-4">
{availableStores.map((store) => {
const isConnected = connected.includes(store.id)
const isLinking = linking === store.id
return (
<div key={store.id} className="rounded-xl bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<StoreIcon storeId={store.id} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{store.name}</p>
<p className="text-xs text-gray-500">
{isConnected ? 'Connected' : store.description}
</p>
</div>
{isConnected ? (
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-green-500 text-xs text-white">
&#x2713;
</span>
) : null}
</div>
{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"
>
Disconnect
</button>
)}
{!isConnected && !isLinking && (
<button
onClick={() => setLinking(store.id)}
className="mt-3 min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Connect {store.name}
</button>
)}
{isLinking && (
<LinkForm
store={store}
status={status}
onSubmit={() => handleConnect(store.id)}
onCancel={() => {
setLinking(null)
setStatus('idle')
}}
/>
)}
</div>
)
})}
</div>
<div className="mt-6 rounded-xl bg-blue-50 p-4">
<p className="text-xs text-blue-700">
Your credentials are encrypted and stored securely. CartSnitch never shares your login
information with third parties.
</p>
</div>
</div>
)
}
function LinkForm({
store,
status,
onSubmit,
onCancel,
}: {
store: StoreConfig
status: string
onSubmit: () => void
onCancel: () => void
}) {
return (
<div className="mt-3 space-y-3">
{store.fields.map((field) => (
<input
key={field.key}
type={field.type}
placeholder={field.label}
autoComplete={field.type === 'password' ? 'current-password' : field.type}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
))}
{status === 'connecting' && (
<div className="flex items-center gap-2 rounded-xl bg-blue-50 px-4 py-3">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-brand-blue border-t-transparent" />
<span className="text-sm text-blue-700">Connecting to {store.name}...</span>
</div>
)}
{status === 'success' && (
<div className="rounded-xl bg-green-50 px-4 py-3 text-sm text-green-700">
Connected successfully!
</div>
)}
{status === 'error' && (
<div className="rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
Connection failed. Please check your credentials and try again.
</div>
)}
{status === 'idle' && (
<div className="flex gap-3">
<button
onClick={onSubmit}
className="min-h-12 flex-1 rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Connect
</button>
<button
onClick={onCancel}
className="min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
>
Cancel
</button>
</div>
)}
</div>
)
}
+203
View File
@@ -0,0 +1,203 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { mockAlerts } from '../lib/mock-data.ts'
import type { PriceAlert } from '../types/api.ts'
export function Alerts() {
const [alerts, setAlerts] = useState<PriceAlert[]>(mockAlerts)
const [showCreate, setShowCreate] = useState(false)
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))
}
return (
<div>
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Price Alerts</h1>
<button
onClick={() => setShowCreate(!showCreate)}
className="min-h-10 rounded-full bg-brand-blue px-4 text-sm font-medium text-white active:bg-brand-blue/90"
>
+ New Alert
</button>
</div>
{/* Create alert form */}
{showCreate && <CreateAlertForm onClose={() => setShowCreate(false)} onCreated={(a) => {
setAlerts((prev) => [a, ...prev])
setShowCreate(false)
}} />}
{/* Triggered alerts */}
{triggered.length > 0 && (
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-green-700">
Triggered ({triggered.length})
</h2>
<div className="space-y-3">
{triggered.map((alert) => (
<AlertCard key={alert.id} alert={alert} onDelete={handleDelete} />
))}
</div>
</section>
)}
{/* Watching alerts */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">
Watching ({watching.length})
</h2>
{watching.length === 0 ? (
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
<p className="text-sm text-gray-500">
No active alerts.{' '}
<Link to="/products" className="text-brand-blue">
Search products
</Link>{' '}
to set one up.
</p>
</div>
) : (
<div className="space-y-3">
{watching.map((alert) => (
<AlertCard key={alert.id} alert={alert} onDelete={handleDelete} />
))}
</div>
)}
</section>
</div>
)
}
function AlertCard({
alert,
onDelete,
}: {
alert: PriceAlert
onDelete: (id: string) => void
}) {
const priceDiff = alert.currentPrice - alert.targetPrice
const isBelow = priceDiff <= 0
return (
<div
className={`rounded-xl p-4 shadow-sm ${
alert.triggered ? 'border border-green-200 bg-green-50' : 'bg-white'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<Link to={`/products/${alert.productId}`} className="text-sm font-medium text-gray-900">
{alert.productName}
</Link>
<div className="mt-1 flex items-center gap-2">
<span className="text-xs text-gray-500">Target: ${alert.targetPrice.toFixed(2)}</span>
<span className="text-xs text-gray-400">&middot;</span>
<span className={`text-xs font-medium ${isBelow ? 'text-green-700' : 'text-gray-500'}`}>
Now: ${alert.currentPrice.toFixed(2)}
</span>
</div>
{alert.triggered && (
<p className="mt-1 text-xs font-medium text-green-700">
Price dropped ${Math.abs(priceDiff).toFixed(2)} below target
</p>
)}
</div>
{/* Status indicator */}
<div className="flex items-center gap-2">
{alert.triggered && (
<span className="flex h-3 w-3 rounded-full bg-green-500" />
)}
<button
onClick={() => onDelete(alert.id)}
className="min-h-10 min-w-10 rounded-lg p-2 text-gray-400 active:bg-gray-100"
aria-label="Delete alert"
>
<svg className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
</button>
</div>
</div>
{/* Progress bar toward target */}
{!alert.triggered && (
<div className="mt-3">
<div className="h-1.5 rounded-full bg-gray-100">
<div
className="h-1.5 rounded-full bg-brand-blue-light"
style={{
width: `${Math.min(100, Math.max(5, (1 - priceDiff / alert.currentPrice) * 100))}%`,
}}
/>
</div>
</div>
)}
</div>
)
}
function CreateAlertForm({
onClose,
onCreated,
}: {
onClose: () => void
onCreated: (alert: PriceAlert) => void
}) {
const [productName, setProductName] = useState('')
const [targetPrice, setTargetPrice] = useState('')
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!productName || !targetPrice) return
onCreated({
id: `a-${Date.now()}`,
productId: `prod-${Date.now()}`,
productName,
targetPrice: parseFloat(targetPrice),
currentPrice: parseFloat(targetPrice) + 0.50,
triggered: false,
})
}
return (
<form onSubmit={handleSubmit} className="mt-4 space-y-3 rounded-xl bg-white p-4 shadow-sm">
<input
type="text"
placeholder="Product name"
value={productName}
onChange={(e) => setProductName(e.target.value)}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="number"
step="0.01"
placeholder="Target price"
value={targetPrice}
onChange={(e) => setTargetPrice(e.target.value)}
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<div className="flex gap-3">
<button
type="submit"
className="min-h-12 flex-1 rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Create Alert
</button>
<button
type="button"
onClick={onClose}
className="min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
>
Cancel
</button>
</div>
</form>
)
}
+71
View File
@@ -0,0 +1,71 @@
import { useState } from 'react'
import { mockCoupons } from '../lib/mock-data.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function Coupons() {
const [copied, setCopied] = useState<string | null>(null)
function handleCopy(code: string, id: string) {
navigator.clipboard?.writeText(code)
setCopied(id)
setTimeout(() => setCopied(null), 2000)
}
const storeIds: Record<string, string> = {
Meijer: 'meijer',
Kroger: 'kroger',
Target: 'target',
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Coupons & Deals</h1>
<div className="mt-4 space-y-3">
{mockCoupons.map((coupon) => {
const isExpiringSoon =
new Date(coupon.expiresAt).getTime() - Date.now() < 7 * 24 * 60 * 60 * 1000
return (
<div key={coupon.id} className="rounded-xl bg-white p-4 shadow-sm">
<div className="flex items-start gap-3">
<StoreIcon storeId={storeIds[coupon.storeName] ?? 'unknown'} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{coupon.description}</p>
<p className="mt-0.5 text-xs text-gray-500">{coupon.storeName}</p>
<p
className={`mt-1 text-xs ${
isExpiringSoon ? 'font-medium text-orange-600' : 'text-gray-400'
}`}
>
Expires{' '}
{new Date(coupon.expiresAt).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
{isExpiringSoon && ' — expiring soon!'}
</p>
</div>
<span className="shrink-0 rounded-lg bg-green-100 px-2 py-1 text-sm font-bold text-green-700">
{coupon.discount}
</span>
</div>
{coupon.code && (
<button
onClick={() => handleCopy(coupon.code!, coupon.id)}
className="mt-3 flex min-h-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"
>
<span className="text-gray-700">{coupon.code}</span>
<span className="text-xs text-brand-blue">
{copied === coupon.id ? 'Copied!' : 'Tap to copy'}
</span>
</button>
)}
</div>
)
})}
</div>
</div>
)
}
+178
View File
@@ -0,0 +1,178 @@
import { Link } from 'react-router-dom'
import { LineChart, Line, ResponsiveContainer } from 'recharts'
import { useAuthStore } from '../stores/auth.ts'
import { mockPurchases, mockAlerts, getMockPriceHistory } from '../lib/mock-data.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
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">
<h1 className="text-2xl font-bold text-gray-900">CartSnitch</h1>
<p className="mt-2 text-sm text-gray-500">Track prices. Save money.</p>
<div className="mt-8 space-y-3">
<Link
to="/login"
className="block min-h-12 rounded-xl bg-brand-blue px-4 py-3 text-center text-base font-medium text-white active:bg-brand-blue/90"
>
Sign In
</Link>
<Link
to="/register"
className="block min-h-12 rounded-xl border border-gray-200 px-4 py-3 text-center text-base font-medium text-gray-700 active:bg-gray-50"
>
Create Account
</Link>
</div>
</div>
)
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">
Hi, {user?.name?.split(' ')[0] ?? 'there'}
</h1>
{/* Triggered alerts banner */}
{triggeredAlerts.length > 0 && (
<Link
to="/alerts"
className="mt-4 flex items-center gap-3 rounded-xl bg-green-50 p-4"
>
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-green-500 text-lg text-white">
&#x2713;
</span>
<div>
<p className="text-sm font-semibold text-green-800">
{triggeredAlerts.length} price {triggeredAlerts.length === 1 ? 'alert' : 'alerts'} triggered!
</p>
<p className="text-xs text-green-700">
{triggeredAlerts.map((a) => a.productName).join(', ')}
</p>
</div>
</Link>
)}
{/* Quick stats */}
<div className="mt-4 grid grid-cols-2 gap-3">
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">Watching</p>
<p className="mt-1 text-2xl font-bold text-gray-900">{watchingAlerts.length}</p>
<p className="text-xs text-gray-400">price alerts</p>
</div>
<div className="rounded-xl bg-white p-4 shadow-sm">
<p className="text-xs font-medium text-gray-500">This Month</p>
<p className="mt-1 text-2xl font-bold text-gray-900">
${recentPurchases.reduce((sum, p) => sum + p.total, 0).toFixed(0)}
</p>
<p className="text-xs text-gray-400">grocery spend</p>
</div>
</div>
{/* Price trend sparklines */}
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price Trends</h2>
<div className="space-y-3">
<SparklineCard label="Eggs (dozen)" data={sparklineData} current="$5.44" />
<SparklineCard label="Whole Milk (1 gal)" data={milkSparkline} current="$3.29" />
</div>
</section>
{/* Recent purchases */}
<section className="mt-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-700">Recent Purchases</h2>
<Link to="/purchases" className="text-sm text-brand-blue">
View all
</Link>
</div>
<div className="mt-3 space-y-3">
{recentPurchases.map((purchase) => (
<Link
key={purchase.id}
to={`/purchases/${purchase.id}`}
className="flex items-center gap-3 rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
>
<StoreIcon storeId={purchase.storeId} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
<p className="text-xs text-gray-500">
{new Date(purchase.date).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}{' '}
&middot; {purchase.items.length} items
</p>
</div>
<span className="text-sm font-semibold text-gray-900">
${purchase.total.toFixed(2)}
</span>
</Link>
))}
</div>
</section>
{/* Quick actions */}
<section className="mt-6 pb-4">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Quick Actions</h2>
<div className="grid grid-cols-2 gap-3">
<Link
to="/products"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Compare Prices
</Link>
<Link
to="/settings"
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-700 shadow-sm active:bg-gray-50"
>
Link a Store
</Link>
</div>
</section>
</div>
)
}
function SparklineCard({
label,
data,
current,
}: {
label: string
data: { date: string; price: number }[]
current: string
}) {
return (
<div className="flex items-center gap-4 rounded-xl bg-white p-4 shadow-sm">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{label}</p>
<p className="text-lg font-bold text-gray-900">{current}</p>
</div>
<div className="h-10 w-24">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={data}>
<Line
type="monotone"
dataKey="price"
stroke="#1e40af"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
export function ForgotPassword() {
const [email, setEmail] = useState('')
const [submitted, setSubmitted] = useState(false)
function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (email) setSubmitted(true)
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Reset Password</h1>
<p className="mb-8 max-w-sm text-center text-sm text-gray-500">
Enter your email and we'll send you a link to reset your password.
</p>
{submitted ? (
<div className="w-full max-w-sm rounded-xl bg-green-50 px-4 py-4 text-center">
<p className="text-sm text-green-700">
If an account exists for <strong>{email}</strong>, you'll receive a reset link shortly.
</p>
<Link
to="/login"
className="mt-4 inline-block min-h-12 rounded-xl bg-brand-blue px-6 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Back to Sign In
</Link>
</div>
) : (
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Send Reset Link
</button>
</form>
)}
<p className="mt-6 text-sm text-gray-500">
<Link to="/login" className="text-brand-blue">
Back to Sign In
</Link>
</p>
</div>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!email || !password) {
setError('Please fill in all fields.')
return
}
setLoading(true)
try {
const res = await api.post<{ user: User; token: string }>('/auth/login', { email, password })
setAuth(res.user, res.token)
navigate('/')
} catch {
// Fallback to mock auth for demo
setAuth(mockUser, 'mock-jwt-token')
navigate('/')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<Link to="/forgot-password" className="mt-4 text-sm text-brand-blue">
Forgot password?
</Link>
<p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '}
<Link to="/register" className="text-brand-blue">
Sign up
</Link>
</p>
</div>
)
}
+187
View File
@@ -0,0 +1,187 @@
import { useParams, Link } from 'react-router-dom'
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
Legend,
ResponsiveContainer,
} from 'recharts'
import { mockProducts, getMockPriceHistory } from '../lib/mock-data.ts'
const storeLineColors: Record<string, string> = {
meijer: '#e31837',
kroger: '#0068a8',
target: '#cc0000',
}
export function ProductDetail() {
const { id } = useParams<{ id: string }>()
const product = mockProducts.find((p) => p.id === id)
if (!product) {
return (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Product not found.</p>
<Link to="/products" className="mt-4 inline-block text-sm text-brand-blue">
Back to products
</Link>
</div>
)
}
const history = getMockPriceHistory(product.id)
const lowestPrice = Math.min(...product.prices.map((p) => p.price))
// Reshape history for chart: { date, meijer, kroger, target }
const chartData: Record<string, string | number>[] = []
const dateMap = new Map<string, Record<string, string | number>>()
for (const h of history) {
if (!dateMap.has(h.date)) {
dateMap.set(h.date, { date: h.date })
}
dateMap.get(h.date)![h.storeId] = h.price
}
for (const entry of dateMap.values()) {
chartData.push(entry)
}
return (
<div>
{/* Back link */}
<Link to="/products" className="inline-flex items-center gap-1 text-sm text-brand-blue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Products
</Link>
{/* Product header */}
<div className="mt-4">
<h1 className="text-2xl font-bold text-gray-900">{product.name}</h1>
<p className="text-sm text-gray-500">
{product.brand} &middot; {product.category}
</p>
</div>
{/* Price history chart */}
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Price History (90 days)</h2>
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="h-52">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<XAxis
dataKey="date"
tick={{ fontSize: 10 }}
tickFormatter={(d: string) => {
const dt = new Date(d)
return `${dt.getMonth() + 1}/${dt.getDate()}`
}}
interval="preserveStartEnd"
/>
<YAxis
tick={{ fontSize: 10 }}
domain={['auto', 'auto']}
tickFormatter={(v: number) => `$${v.toFixed(2)}`}
/>
<Tooltip
formatter={(value) => `$${Number(value).toFixed(2)}`}
labelFormatter={(label) =>
new Date(String(label)).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})
}
/>
<Legend />
{['meijer', 'kroger', 'target'].map((store) => (
<Line
key={store}
type="monotone"
dataKey={store}
name={store.charAt(0).toUpperCase() + store.slice(1)}
stroke={storeLineColors[store]}
strokeWidth={2}
dot={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
</div>
</section>
{/* Store comparison table */}
<section className="mt-6">
<h2 className="mb-3 text-lg font-semibold text-gray-700">Store Comparison</h2>
<div className="rounded-xl bg-white shadow-sm">
<div className="divide-y divide-gray-100">
{product.prices
.slice()
.sort((a, b) => a.price - b.price)
.map((pp) => (
<div
key={pp.storeId}
className="flex items-center justify-between px-4 py-3"
>
<div className="flex items-center gap-3">
<span
className={`inline-flex h-8 w-8 items-center justify-center rounded-full text-xs font-bold text-white ${
pp.storeId === 'meijer'
? 'bg-meijer-red'
: pp.storeId === 'kroger'
? 'bg-kroger-blue'
: 'bg-target-red'
}`}
>
{pp.storeName.charAt(0)}
</span>
<div>
<p className="text-sm font-medium text-gray-900">{pp.storeName}</p>
<p className="text-xs text-gray-500">
Updated{' '}
{new Date(pp.lastUpdated).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</p>
</div>
</div>
<div className="text-right">
<p
className={`text-sm font-bold ${
pp.price === lowestPrice ? 'text-green-700' : 'text-gray-900'
}`}
>
${pp.price.toFixed(2)}
</p>
{pp.price === lowestPrice && (
<span className="text-xs text-green-600">Best price</span>
)}
</div>
</div>
))}
</div>
</div>
</section>
{/* Actions */}
<div className="mt-6 space-y-3 pb-4">
<Link
to="/alerts"
className="flex min-h-12 items-center justify-center rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
Set Price Alert
</Link>
<Link
to={`/compare/${product.id}`}
className="flex min-h-12 items-center justify-center rounded-xl border border-gray-200 px-4 py-3 text-base font-medium text-gray-700 active:bg-gray-50"
>
Compare at Nearby Stores
</Link>
</div>
</div>
)
}
+86
View File
@@ -0,0 +1,86 @@
import { useState, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { mockProducts } from '../lib/mock-data.ts'
export function Products() {
const [search, setSearch] = useState('')
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]) =>
Math.min(...product.prices.map((p) => p.price))
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
{/* Search input */}
<div className="mt-4">
<input
type="search"
placeholder="Search products..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="min-h-12 w-full rounded-xl border border-gray-200 bg-white px-4 text-base shadow-sm focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
</div>
{/* Product list */}
<div className="mt-4 space-y-3">
{filtered.length === 0 ? (
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
<p className="text-sm text-gray-500">No products match "{search}".</p>
</div>
) : (
filtered.map((product) => {
const low = lowestPrice(product)
const cheapest = product.prices.find((p) => p.price === low)
return (
<Link
key={product.id}
to={`/products/${product.id}`}
className="block rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{product.name}</p>
<p className="text-xs text-gray-500">
{product.brand} &middot; {product.category}
</p>
</div>
<div className="text-right">
<p className="text-sm font-bold text-green-700">${low.toFixed(2)}</p>
<p className="text-xs text-gray-500">{cheapest?.storeName}</p>
</div>
</div>
<div className="mt-2 flex gap-2">
{product.prices.map((pp) => (
<span
key={pp.storeId}
className={`rounded-full px-2 py-0.5 text-xs ${
pp.price === low
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{pp.storeName} ${pp.price.toFixed(2)}
</span>
))}
</div>
</Link>
)
})
)}
</div>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
import { useParams, Link } from 'react-router-dom'
import { mockPurchases } from '../lib/mock-data.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function PurchaseDetail() {
const { id } = useParams<{ id: string }>()
const purchase = mockPurchases.find((p) => p.id === id)
if (!purchase) {
return (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Purchase not found.</p>
<Link to="/purchases" className="mt-4 inline-block text-sm text-brand-blue">
Back to purchases
</Link>
</div>
)
}
return (
<div>
{/* Back link */}
<Link to="/purchases" className="inline-flex items-center gap-1 text-sm text-brand-blue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
Purchases
</Link>
{/* Receipt header */}
<div className="mt-4 rounded-xl bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<StoreIcon storeId={purchase.storeId} />
<div>
<h1 className="text-lg font-bold text-gray-900">{purchase.storeName}</h1>
<p className="text-sm text-gray-500">
{new Date(purchase.date).toLocaleDateString('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
</div>
</div>
{/* Line items */}
<div className="mt-4 rounded-xl bg-white shadow-sm">
<div className="divide-y divide-gray-100">
{purchase.items.map((item) => (
<Link
key={item.id}
to={`/products/${item.productId}`}
className="flex items-center justify-between px-4 py-3 active:bg-gray-50"
>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{item.name}</p>
{item.quantity > 1 && (
<p className="text-xs text-gray-500">
{item.quantity} × ${item.unitPrice.toFixed(2)}
</p>
)}
</div>
<span className="ml-4 text-sm font-medium text-gray-900">
${item.price.toFixed(2)}
</span>
</Link>
))}
</div>
{/* Total */}
<div className="border-t-2 border-gray-200 px-4 py-3">
<div className="flex items-center justify-between">
<span className="text-base font-bold text-gray-900">Total</span>
<span className="text-base font-bold text-gray-900">
${purchase.total.toFixed(2)}
</span>
</div>
</div>
</div>
</div>
)
}
+83
View File
@@ -0,0 +1,83 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import { mockPurchases } from '../lib/mock-data.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
const stores = ['all', ...new Set(mockPurchases.map((p) => p.storeName))]
export function Purchases() {
const [storeFilter, setStoreFilter] = useState('all')
const filtered =
storeFilter === 'all'
? mockPurchases
: mockPurchases.filter((p) => p.storeName === storeFilter)
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Purchase History</h1>
{/* Store filter chips */}
<div className="mt-4 flex gap-2 overflow-x-auto pb-1">
{stores.map((store) => (
<button
key={store}
onClick={() => setStoreFilter(store)}
className={`min-h-10 shrink-0 rounded-full px-4 text-sm font-medium ${
storeFilter === store
? 'bg-brand-blue text-white'
: 'bg-white text-gray-700 shadow-sm'
}`}
>
{store === 'all' ? 'All Stores' : store}
</button>
))}
</div>
{/* Purchase list */}
<div className="mt-4 space-y-3">
{filtered.length === 0 ? (
<div className="rounded-xl bg-white p-6 text-center shadow-sm">
<p className="text-sm text-gray-500">No purchases found for this filter.</p>
</div>
) : (
filtered.map((purchase) => (
<Link
key={purchase.id}
to={`/purchases/${purchase.id}`}
className="block rounded-xl bg-white p-4 shadow-sm active:bg-gray-50"
>
<div className="flex items-center gap-3">
<StoreIcon storeId={purchase.storeId} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{purchase.storeName}</p>
<p className="text-xs text-gray-500">
{new Date(purchase.date).toLocaleDateString('en-US', {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
</div>
<div className="text-right">
<p className="text-sm font-semibold text-gray-900">${purchase.total.toFixed(2)}</p>
<p className="text-xs text-gray-500">{purchase.items.length} items</p>
</div>
</div>
{/* Item preview */}
<p className="mt-2 truncate text-xs text-gray-400">
{purchase.items
.slice(0, 3)
.map((i) => i.name)
.join(', ')}
{purchase.items.length > 3 && ` +${purchase.items.length - 3} more`}
</p>
</Link>
))
)}
</div>
</div>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { useState } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { api } from '../lib/api.ts'
import { mockUser } from '../lib/mock-data.ts'
import type { User } from '../types/api.ts'
export function Register() {
const [name, setName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
const setAuth = useAuthStore((s) => s.setAuth)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
if (!name || !email || !password) {
setError('Please fill in all fields.')
return
}
if (password.length < 8) {
setError('Password must be at least 8 characters.')
return
}
setLoading(true)
try {
const res = await api.post<{ user: User; token: string }>('/auth/register', { name, email, password })
setAuth(res.user, res.token)
navigate('/')
} catch {
// Fallback to mock auth for demo
setAuth({ ...mockUser, name, email }, 'mock-jwt-token')
navigate('/')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">Create Account</h1>
<p className="mb-8 text-sm text-gray-500">Start tracking your grocery prices.</p>
{error && (
<div className="mb-4 w-full max-w-sm rounded-xl bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<form className="w-full max-w-sm space-y-4" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Full Name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="name"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<input
type="password"
placeholder="Password (min. 8 characters)"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
className="min-h-12 w-full rounded-xl border border-gray-200 px-4 text-base focus:border-brand-blue focus:outline-none focus:ring-1 focus:ring-brand-blue"
/>
<button
type="submit"
disabled={loading}
className="min-h-12 w-full rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90 disabled:opacity-60"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<p className="mt-6 text-sm text-gray-500">
Already have an account?{' '}
<Link to="/login" className="text-brand-blue">
Sign in
</Link>
</p>
</div>
)
}
+134
View File
@@ -0,0 +1,134 @@
import { Link, useNavigate } from 'react-router-dom'
import { useAuthStore } from '../stores/auth.ts'
import { useThemeStore } from '../stores/theme.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function Settings() {
const user = useAuthStore((s) => s.user)
const logout = useAuthStore((s) => s.logout)
const navigate = useNavigate()
const { theme, setTheme } = useThemeStore()
const connectedStores = user?.connectedStores ?? []
function handleSignOut() {
logout()
navigate('/login')
}
return (
<div>
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
{/* Profile section */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Profile</h2>
<div className="rounded-xl bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-brand-blue text-lg font-bold text-white">
{user?.name?.charAt(0) ?? '?'}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{user?.name ?? 'Guest'}</p>
<p className="truncate text-xs text-gray-500">{user?.email ?? ''}</p>
</div>
</div>
</div>
</section>
{/* Connected stores */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Connected Stores</h2>
<div className="rounded-xl bg-white shadow-sm">
{connectedStores.length > 0 ? (
<div className="divide-y divide-gray-100">
{connectedStores.map((storeId) => (
<div key={storeId} className="flex items-center gap-3 px-4 py-3">
<StoreIcon storeId={storeId} size="sm" />
<span className="text-sm font-medium text-gray-900 capitalize">{storeId}</span>
<span className="ml-auto text-xs text-green-600">Connected</span>
</div>
))}
</div>
) : (
<div className="p-4">
<p className="text-sm text-gray-500">No stores connected yet.</p>
</div>
)}
<div className="border-t border-gray-100 p-3">
<Link
to="/account-linking"
className="flex min-h-12 items-center justify-center rounded-xl bg-brand-blue px-4 py-3 text-base font-medium text-white active:bg-brand-blue/90"
>
{connectedStores.length > 0 ? 'Manage Stores' : 'Connect a Store'}
</Link>
</div>
</div>
</section>
{/* Notifications */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Notifications</h2>
<div className="rounded-xl bg-white shadow-sm">
<SettingsToggle label="Price alert notifications" defaultChecked />
<SettingsToggle label="Weekly deals digest" defaultChecked />
<SettingsToggle label="Purchase import confirmations" />
</div>
</section>
{/* Appearance */}
<section className="mt-6">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Appearance</h2>
<div className="rounded-xl bg-white shadow-sm">
<div className="flex gap-2 p-3">
{(['light', 'dark', 'system'] as const).map((t) => (
<button
key={t}
onClick={() => setTheme(t)}
className={`min-h-10 flex-1 rounded-lg px-3 py-2 text-sm font-medium capitalize ${
theme === t
? 'bg-brand-blue text-white'
: 'bg-gray-100 text-gray-700 active:bg-gray-200'
}`}
>
{t}
</button>
))}
</div>
</div>
</section>
{/* Account actions */}
<section className="mt-6 pb-4">
<h2 className="mb-3 text-sm font-semibold text-gray-500">Account</h2>
<div className="rounded-xl bg-white shadow-sm">
<button
onClick={handleSignOut}
className="min-h-12 w-full rounded-xl px-4 py-3 text-base font-medium text-red-600 active:bg-red-50"
>
Sign Out
</button>
</div>
</section>
</div>
)
}
function SettingsToggle({
label,
defaultChecked = false,
}: {
label: string
defaultChecked?: boolean
}) {
return (
<label className="flex min-h-12 cursor-pointer items-center justify-between px-4 py-3">
<span className="text-sm text-gray-900">{label}</span>
<input
type="checkbox"
defaultChecked={defaultChecked}
className="h-5 w-5 rounded border-gray-300 text-brand-blue focus:ring-brand-blue"
/>
</label>
)
}
+93
View File
@@ -0,0 +1,93 @@
import { useParams, Link } from 'react-router-dom'
import { mockProducts } from '../lib/mock-data.ts'
import { StoreIcon } from '../components/StoreIcon.tsx'
export function StoreComparison() {
const { productId } = useParams<{ productId: string }>()
const product = mockProducts.find((p) => p.id === productId)
if (!product) {
return (
<div className="py-8 text-center">
<p className="text-sm text-gray-500">Product not found.</p>
<Link to="/products" className="mt-4 inline-block text-sm text-brand-blue">
Back to products
</Link>
</div>
)
}
const sorted = product.prices.slice().sort((a, b) => a.price - b.price)
const lowestPrice = sorted[0]?.price ?? 0
const savings = sorted.length > 1 ? sorted[sorted.length - 1].price - sorted[0].price : 0
return (
<div>
{/* Back link */}
<Link to={`/products/${product.id}`} className="inline-flex items-center gap-1 text-sm text-brand-blue">
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
{product.name}
</Link>
<h1 className="mt-4 text-2xl font-bold text-gray-900">Store Comparison</h1>
<p className="mt-1 text-sm text-gray-500">{product.name} &middot; {product.brand}</p>
{/* Savings banner */}
{savings > 0 && (
<div className="mt-4 rounded-xl bg-green-50 p-4">
<p className="text-sm font-semibold text-green-800">
Save ${savings.toFixed(2)} by shopping at {sorted[0].storeName}
</p>
</div>
)}
{/* Store comparison cards */}
<div className="mt-4 space-y-3">
{sorted.map((pp, idx) => (
<div
key={pp.storeId}
className={`rounded-xl p-4 shadow-sm ${
idx === 0 ? 'border-2 border-green-400 bg-white' : 'bg-white'
}`}
>
<div className="flex items-center gap-3">
<StoreIcon storeId={pp.storeId} />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900">{pp.storeName}</p>
<p className="text-xs text-gray-500">
Updated{' '}
{new Date(pp.lastUpdated).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
})}
</p>
</div>
<div className="text-right">
<p
className={`text-lg font-bold ${
pp.price === lowestPrice ? 'text-green-700' : 'text-gray-900'
}`}
>
${pp.price.toFixed(2)}
</p>
{pp.price === lowestPrice ? (
<span className="text-xs font-medium text-green-600">Best price</span>
) : (
<span className="text-xs text-gray-400">
+${(pp.price - lowestPrice).toFixed(2)}
</span>
)}
</div>
</div>
</div>
))}
</div>
<p className="mt-6 text-center text-xs text-gray-400">
Prices last verified from store loyalty card data. Map view coming soon.
</p>
</div>
)
}