forked from cartsnitch/app
5fbf0f5c5c
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>
204 lines
7.1 KiB
TypeScript
204 lines
7.1 KiB
TypeScript
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">·</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>
|
|
)
|
|
}
|