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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user