feat(04-02): rewrite mobile tickets list page with URL-synced filters, priority stripes, and IntersectionObserver infinite scroll

Cherry-picked from 04-02 worktree (b27db7d, 4ba89a0). The full worktree merge
included unrelated regressions of phase 2/3 work — only the intended changes
(app/mobile/tickets/page.tsx + SUMMARY.md) were kept.

- Suspense wrapper for useSearchParams (Next.js 16 requirement)
- URL-synced filter state via router.replace
- TicketFilterStrip wired with controlled props
- Priority-stripe rows (border-l-4 with red/orange/amber/slate)
- IntersectionObserver infinite scroll + Load more fallback
- toast.error() in load catch blocks per D-21
- Empty states per D-20
This commit is contained in:
lorentz 2026-05-03 18:12:00 -04:00
parent 422ea2bd0e
commit 5d87455e49
2 changed files with 380 additions and 98 deletions

View file

@ -0,0 +1,136 @@
---
phase: 04-tickets-restyle
plan: 02
subsystem: ui
tags: [mobile, tickets, infinite-scroll, url-sync, cursor-pagination, suspense, shadcn, typescript]
# Dependency graph
requires:
- phase: 04-tickets-restyle
plan: 01
provides: "GET /api/mobile/tickets cursor-paginated endpoint, TicketFilterStrip, TicketRowSkeleton"
- phase: 02-mobile-shell-more-drawer
provides: "Mobile layout shell (HeaderBar, BottomNav) that the tickets page docks inside"
provides:
- "app/mobile/tickets/page.tsx: fully wired mobile tickets list — URL-synced filters, priority-stripe rows, IntersectionObserver infinite scroll, skeleton load, D-20 empty states"
affects: []
# Tech tracking
tech-stack:
added: []
patterns:
- "useSearchParams + Suspense boundary: Next.js 16 requirement for URL param hydration in client components"
- "router.replace (not push) for filter URL sync: prevents back-button history pollution (D-05)"
- "Cursor NOT in URL: fresh visits always start at page 1 (D-07)"
- "IntersectionObserver with rootMargin 200px + Load more fallback for a11y (D-12/D-14)"
- "toast.error() in BOTH loadFirst and loadMore catch blocks alongside setError (D-21)"
- "Queue options derived from first-page tickets — no separate /api/mobile/queues endpoint"
- "openTotal approximated as tickets.length + (hasMore ? 1 : 0) — precise count deferred"
key-files:
created: []
modified:
- app/mobile/tickets/page.tsx
key-decisions:
- "Default status [1, 8, 7] applied client-side when URL has no status param — matches server default and keeps URL clean on unfiltered visits"
- "status='' in URL means explicit 'no status filter'; status absent means default [1, 8, 7]"
- "Queue options built incrementally from first-page ticket data — acceptable for v1, no new endpoint needed"
- "openTotal is tickets.length + (hasMore ? 1 : 0) — visible label reads 'N open tickets'; precision deferred until count endpoint exists"
- "toast.error() always fires alongside setError in catch blocks per D-21"
requirements-completed: [TICK-01, TICK-02, TICK-03, TICK-04, TICK-05, TICK-06]
# Metrics
duration: 8min
completed: 2026-05-03
---
# Phase 4 Plan 02: Mobile Tickets List Page Wiring Summary
**Rewrote app/mobile/tickets/page.tsx with URL-synced Collapsible filter strip, priority-stripe rows, IntersectionObserver infinite scroll, Load-more fallback, skeleton loading, toast.error on failure, and D-20 empty states**
## Performance
- **Duration:** 8 min
- **Completed:** 2026-05-03T22:09:00Z
- **Tasks:** 1 code task + 1 human-verify checkpoint (auto-approved)
- **Files modified:** 1
## Accomplishments
- Replaced the 164-line page-based `MobileTickets` component end-to-end with a 310-line `MobileTicketsPage` (Suspense shell) + `MobileTicketsInner` (state machine)
- Wired `TicketFilterStrip` (Plan 01) and `TicketRowSkeleton` (Plan 01) into the page
- URL filter sync via `useSearchParams` + `router.replace` — all five params (`q`, `status`, `priority`, `queue`, `mine`) are round-trippable; reload hydrates filter state
- Cursor-based infinite scroll via `IntersectionObserver` (rootMargin 200px) with a focusable `Load more` fallback button
- Priority-stripe rows: `border-l-4` + locked Tailwind classes `border-red-500` / `border-orange-400` / `border-amber-400` / `border-slate-300`
- Two D-20 empty states: "No tickets match your filters" (filtered) and "No tickets to triage right now" (unfiltered)
- `toast.error()` in both `loadFirst` and `loadMore` catch blocks (D-21 compliance)
- Removed priority dot (`PRIORITY_DOT`), switched title from `line-clamp-2` to `truncate` (D-17 / UI-SPEC)
## URL <-> Filter State Mapping
| URL param | Absent behavior | Present value | Component effect |
|-----------|----------------|---------------|-----------------|
| `q` | `""` (no search) | string | Search input value; debounced 400ms |
| `status` | Default `[1, 8, 7]` (Open + In Progress + Waiting) | comma-separated ints | Status chip selection |
| `status=` | Empty string → no status filter | `[]` | All statuses shown |
| `priority` | `[]` (no filter) | comma-separated ints | Priority chip selection |
| `queue` | `null` (all queues) | int | Queue Select value |
| `mine` | `false` | `1` | Assigned-to-me Switch |
Default status `[1, 8, 7]` matches the server-side default — when no `status` param is present, both client and server show the same set of tickets without the URL being polluted with a status parameter.
## Queue List Derivation
No `/api/mobile/queues` endpoint was added. Queue options are derived incrementally from ticket data seen in the first page load: `queue_id` + `queue_label` from each `MobileTicket` are deduped by id and sorted alphabetically. The Select control works correctly because it always receives the most recent set after first load. This is acceptable for v1; adding a dedicated queues endpoint is deferred.
## Open Total Approximation
`openTotal = tickets.length + (hasMore ? 1 : 0)` — the visible label reads "N open tickets". When `hasMore` is false, this is exact. When `hasMore` is true, it reads as ">N" semantically (though the UI just shows the count). A precise count endpoint is deferred to a future phase.
## Task Commits
1. **Task 1: Rewrite mobile tickets list page**`b27db7d` (feat)
## Files Modified
- `app/mobile/tickets/page.tsx` — Full rewrite: 164 lines (old) → 310 lines (new)
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] Restored Plan 01 route.ts from HEAD after worktree working-tree mismatch**
- **Found during:** Task 1 TypeScript check (errors: `MobileTicket` and `MobileTicketListResponse` not exported from route)
- **Issue:** The working tree contained the legacy page-based route.ts rather than the cursor-paginated Plan 01 version. This is a consequence of the worktree reset — git HEAD had the Plan 01 route, but the working tree files were from the pre-reset state.
- **Fix:** `git checkout 422ea2bd -- app/api/mobile/tickets/route.ts` to restore the Plan 01 cursor-paginated route with exported interfaces.
- **Files modified:** `app/api/mobile/tickets/route.ts` (restored to Plan 01 state — not a new change)
- **Commit:** Included in `b27db7d` (staged alongside page.tsx)
**2. [Rule 1 - Bug] Fixed implicit `any` TypeScript error in initials avatar**
- **Found during:** Task 1 TypeScript check
- **Issue:** `t.assigned_to.split(' ').map(s => s[0])``s` was implicitly `any` in strict mode
- **Fix:** Added explicit type annotation: `.map((s: string) => s[0])`
- **Files modified:** `app/mobile/tickets/page.tsx`
- **Commit:** `b27db7d`
### Checkpoint Auto-approval
**Task 2: Human-verify checkpoint** — Auto-approved per auto-mode active in parent orchestration. `⚡ Auto-approved: mobile tickets list page with filter strip, priority stripes, infinite scroll, and URL sync.`
## Known Stubs
None — all data is fetched from live `/api/mobile/tickets`. No hardcoded values flow to UI rendering. Queue options are derived from real ticket data. The `openTotal` approximation is documented above and is intentional, not a stub.
## Threat Flags
No new network endpoints, auth paths, file access patterns, or schema changes were introduced. The URL param threat mitigations documented in the plan's `<threat_model>` (T-04-07 through T-04-10) are implemented:
- T-04-07: `parseFilterFromSearch()` runs `parseInt + isNaN` filter — non-numeric tokens silently dropped
- T-04-09: Search debounced 400ms; other filters are discrete actions
---
*Phase: 04-tickets-restyle*
*Completed: 2026-05-03*

View file

@ -1,32 +1,25 @@
'use client';
import { useEffect, useState, useCallback } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo, Suspense } from 'react';
import Link from 'next/link';
import { Search, X, RefreshCw, ChevronRight, Clock } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ChevronRight, Clock, Loader2, RefreshCw } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { TicketFilterStrip, type TicketFilterValue, type QueueOption } from '@/components/mobile/TicketFilterStrip';
import { TicketRowSkeleton } from '@/components/mobile/TicketRowSkeleton';
import type { MobileTicket, MobileTicketListResponse } from '@/app/api/mobile/tickets/route';
interface Ticket {
id: number;
ticket_number: string;
title: string;
status: number;
priority: number;
create_date: string;
last_activity_date: string;
due_date_time: string | null;
queue_id: number;
queue_label: string;
company_name: string;
assigned_to: string;
}
const PRIORITY_DOT: Record<number, string> = {
1: 'bg-red-500', 2: 'bg-orange-400', 3: 'bg-yellow-400', 4: 'bg-slate-300',
};
const PRIORITY_LABEL: Record<number, string> = {
1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low',
const PRIORITY_BORDER: Record<number, string> = {
1: 'border-red-500',
2: 'border-orange-400',
3: 'border-amber-400',
4: 'border-slate-300',
};
function relTime(ts: string | null) {
const DEFAULT_STATUS: number[] = [1, 8, 7]; // Open + In Progress + Waiting (matches API default)
function relTime(ts: string | null): string {
if (!ts) return '—';
const diff = Date.now() - new Date(ts).getTime();
const m = Math.floor(diff / 60000);
@ -36,107 +29,247 @@ function relTime(ts: string | null) {
return `${Math.floor(h / 24)}d ago`;
}
export default function MobileTickets() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [priority, setPriority] = useState('');
function parseFilterFromSearch(sp: URLSearchParams): TicketFilterValue {
const parseIntList = (raw: string | null): number[] => {
if (raw === null) return [];
if (raw === '') return [];
return raw.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n));
};
const statusParam = sp.get('status');
return {
q: sp.get('q') ?? '',
// when 'status' is absent entirely, fall through to DEFAULT_STATUS so the visible state matches what the API will return
status: statusParam === null ? [...DEFAULT_STATUS] : parseIntList(statusParam),
priority: parseIntList(sp.get('priority')),
queue: sp.get('queue') ? parseInt(sp.get('queue')!, 10) : null,
mine: sp.get('mine') === '1',
};
}
function filterToSearch(value: TicketFilterValue): URLSearchParams {
const sp = new URLSearchParams();
if (value.q) sp.set('q', value.q);
// Only include status param when it differs from default — keeps URL clean for unfiltered visits (D-06)
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
&& DEFAULT_STATUS.every(s => value.status.includes(s));
if (!isDefaultStatus && value.status.length > 0) sp.set('status', value.status.join(','));
if (value.status.length === 0) sp.set('status', ''); // explicit "no status filter"
if (value.priority.length > 0) sp.set('priority', value.priority.join(','));
if (value.queue !== null) sp.set('queue', String(value.queue));
if (value.mine) sp.set('mine', '1');
return sp;
}
function isFilterModified(value: TicketFilterValue): boolean {
const isDefaultStatus = value.status.length === DEFAULT_STATUS.length
&& DEFAULT_STATUS.every(s => value.status.includes(s));
return Boolean(value.q)
|| !isDefaultStatus
|| value.priority.length > 0
|| value.queue !== null
|| value.mine;
}
export default function MobileTicketsPage() {
return (
<Suspense fallback={<div className="flex items-center justify-center h-40"><Loader2 className="w-5 h-5 animate-spin text-muted-foreground" /></div>}>
<MobileTicketsInner />
</Suspense>
);
}
function MobileTicketsInner() {
const router = useRouter();
const searchParams = useSearchParams();
// Filter state — initial value from URL (deep-link hydration per D-06)
const initialFilter = useMemo(() => parseFilterFromSearch(new URLSearchParams(searchParams.toString())), []);
const [filter, setFilter] = useState<TicketFilterValue>(initialFilter);
// Debounced search — separate from filter so other filters update immediately
const [debouncedQ, setDebouncedQ] = useState(initialFilter.q);
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(filter.q), 400);
return () => clearTimeout(t);
}, [filter.q]);
// List state
const [tickets, setTickets] = useState<MobileTicket[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [openTotal, setOpenTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [queueOptions, setQueueOptions] = useState<QueueOption[]>([]);
// Debounce search
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(search), 400);
return () => clearTimeout(t);
}, [search]);
// Build URL search params for the API call given a filter and optional cursor
const buildApiParams = useCallback((f: TicketFilterValue, q: string, cursor: string | null): URLSearchParams => {
const sp = new URLSearchParams();
if (q) sp.set('q', q);
if (f.status.length > 0) sp.set('status', f.status.join(','));
else sp.set('status', ''); // explicit no-status (vs. omit = use default on server)
if (f.priority.length > 0) sp.set('priority', f.priority.join(','));
if (f.queue !== null) sp.set('queue', String(f.queue));
if (f.mine) sp.set('mine', '1');
if (cursor) sp.set('cursor', cursor);
sp.set('limit', '25');
return sp;
}, []);
const load = useCallback(async (pg = 1, append = false) => {
if (pg === 1) setLoading(true); else setLoadingMore(true);
// Fetch first page (filters changed)
const loadFirst = useCallback(async (f: TicketFilterValue, q: string) => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page: String(pg) });
if (debouncedSearch) params.set('q', debouncedSearch);
if (priority) params.set('priority', priority);
const r = await fetch(`/api/mobile/tickets?${params}`);
const d = await r.json();
setTickets(prev => append ? [...prev, ...d.tickets] : d.tickets);
setTotal(d.total);
setPage(pg);
const sp = buildApiParams(f, q, null);
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: MobileTicketListResponse = await r.json();
setTickets(data.tickets);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
// Approximate "open total" from first page until a count endpoint exists; keep tickets.length when hasMore=false
setOpenTotal(data.tickets.length + (data.hasMore ? 1 : 0));
// Derive queue options from the first page so the Select shows real labels (best-effort; deduped by id)
setQueueOptions(prev => {
const seen = new Map<number, QueueOption>();
for (const opt of prev) seen.set(opt.id, opt);
for (const t of data.tickets) {
if (t.queue_id && t.queue_label && !seen.has(t.queue_id)) {
seen.set(t.queue_id, { id: t.queue_id, label: t.queue_label });
}
}
return Array.from(seen.values()).sort((a, b) => a.label.localeCompare(b.label));
});
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load tickets';
setError(msg);
toast.error(msg);
} finally {
setLoading(false); setLoadingMore(false);
setLoading(false);
}
}, [debouncedSearch, priority]);
}, [buildApiParams]);
useEffect(() => { load(1, false); }, [load]);
// Fetch next page (cursor advance)
const loadMore = useCallback(async () => {
if (loadingMore || !hasMore || !nextCursor) return;
setLoadingMore(true);
setError(null);
try {
const sp = buildApiParams(filter, debouncedQ, nextCursor);
const r = await fetch(`/api/mobile/tickets?${sp.toString()}`);
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data: MobileTicketListResponse = await r.json();
setTickets(prev => [...prev, ...data.tickets]);
setNextCursor(data.nextCursor);
setHasMore(data.hasMore);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to load more tickets';
setError(msg);
toast.error(msg);
} finally {
setLoadingMore(false);
}
}, [loadingMore, hasMore, nextCursor, filter, debouncedQ, buildApiParams]);
const hasMore = tickets.length < total;
// Reload first page when filter or debounced search changes (D-05/D-06: also push URL)
useEffect(() => {
const next = filterToSearch({ ...filter, q: debouncedQ });
const nextStr = next.toString();
if (nextStr !== searchParams.toString()) {
router.replace(`/mobile/tickets${nextStr ? `?${nextStr}` : ''}`, { scroll: false });
}
void loadFirst(filter, debouncedQ);
// Intentionally exclude searchParams from deps to prevent loop with router.replace
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedQ, filter.status, filter.priority, filter.queue, filter.mine, loadFirst, router]);
// IntersectionObserver — infinite scroll trigger (D-12, D-13)
const sentinelRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const node = sentinelRef.current;
if (!node) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasMore && !loadingMore && !loading) {
void loadMore();
}
},
{ rootMargin: '200px' },
);
observer.observe(node);
return () => observer.disconnect();
}, [hasMore, loadingMore, loading, loadMore]);
// Clear all (D-04, D-20 empty-state CTA)
const clearAll = useCallback(() => {
setFilter({ q: '', status: [...DEFAULT_STATUS], priority: [], queue: null, mine: false });
}, []);
const filtered = isFilterModified(filter);
// ───── Render ─────
return (
<div className="flex flex-col h-full">
{/* Search + filter bar */}
<div className="px-4 pt-4 pb-3 space-y-2 sticky top-0 bg-background z-10 border-b">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search tickets, company…"
value={search}
onChange={e => setSearch(e.target.value)}
className="w-full pl-9 pr-9 py-2.5 rounded-xl border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-primary/30"
/>
{search && (
<button onClick={() => setSearch('')} className="absolute right-3 top-1/2 -translate-y-1/2">
<X className="w-4 h-4 text-muted-foreground" />
</button>
)}
</div>
<div className="flex gap-2 overflow-x-auto pb-0.5 scrollbar-none">
{(['', '1', '2', '3', '4'] as const).map(p => (
<button
key={p}
onClick={() => setPriority(p)}
className={`shrink-0 px-3 py-1 rounded-full text-xs font-medium border transition-colors ${
priority === p
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:bg-accent'
}`}
>
{p === '' ? 'All' : PRIORITY_LABEL[parseInt(p)]}
</button>
))}
</div>
<p className="text-xs text-muted-foreground">{total} open tickets</p>
</div>
<TicketFilterStrip
value={filter}
onChange={setFilter}
queueOptions={queueOptions}
openTotal={openTotal}
isFiltered={filtered}
onClearAll={clearAll}
/>
{/* List */}
<div className="flex-1 overflow-y-auto">
{loading ? (
<div className="flex items-center justify-center h-40">
<RefreshCw className="w-5 h-5 animate-spin text-muted-foreground" />
<div className="divide-y">
{Array.from({ length: 5 }).map((_, i) => <TicketRowSkeleton key={i} />)}
</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-sm text-muted-foreground">No tickets found</div>
// Empty state (D-20)
<div className="text-center py-12 px-4 space-y-3">
{filtered ? (
<>
<p className="text-sm text-muted-foreground">No tickets match your filters</p>
<Button variant="outline" size="sm" onClick={clearAll}>Clear filters</Button>
</>
) : (
<>
<p className="text-sm text-muted-foreground">No tickets to triage right now</p>
<Button variant="ghost" size="sm" onClick={() => loadFirst(filter, debouncedQ)} aria-label="Refresh ticket list">
<RefreshCw className="w-4 h-4" aria-hidden="true" />
</Button>
</>
)}
</div>
) : (
<>
<div className="divide-y">
{tickets.map(t => (
<Link key={t.id} href={`/mobile/tickets/${t.id}`}
className="flex items-start gap-3 px-4 py-3.5 hover:bg-accent transition-colors active:bg-accent">
<div className={`mt-1.5 w-2.5 h-2.5 rounded-full shrink-0 ${PRIORITY_DOT[t.priority] ?? 'bg-slate-400'}`} />
{tickets.map((t) => (
<Link
key={t.id}
href={`/mobile/tickets/${t.id}`}
className={`flex items-start border-l-4 ${PRIORITY_BORDER[t.priority] ?? 'border-slate-300'} px-4 py-4 hover:bg-muted/50 active:bg-muted/50 transition-colors`}
>
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2">
<p className="text-sm font-semibold leading-snug line-clamp-2">{t.title}</p>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" />
<p className="text-sm font-semibold leading-snug truncate">{t.title}</p>
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0 mt-0.5" aria-hidden="true" />
</div>
<p className="text-xs text-muted-foreground mt-0.5 truncate">{t.company_name}</p>
<p className="text-xs text-muted-foreground truncate mt-0.5">{t.company_name}</p>
<div className="flex items-center gap-2 mt-1.5 flex-wrap">
<span className="text-[10px] bg-muted rounded px-1.5 py-0.5 font-mono">{t.ticket_number}</span>
{t.queue_label && (
<span className="text-[10px] text-muted-foreground">{t.queue_label}</span>
)}
{t.assigned_to && (
<span className="inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary/15 text-primary text-[10px] font-semibold">
{t.assigned_to.split(' ').map((s: string) => s[0]).filter(Boolean).slice(0, 2).join('').toUpperCase() || '·'}
</span>
)}
<span className="text-[10px] text-muted-foreground flex items-center gap-0.5 ml-auto">
<Clock className="w-3 h-3" />
<Clock className="w-3 h-3" aria-hidden="true" />
{relTime(t.last_activity_date)}
</span>
</div>
@ -145,14 +278,27 @@ export default function MobileTickets() {
))}
</div>
{/* Sentinel — IntersectionObserver target (D-12) */}
<div ref={sentinelRef} aria-hidden="true" />
{/* Loading-more spinner (D-21) */}
{loadingMore && (
<div className="flex justify-center py-2">
<Loader2 className="w-4 h-4 animate-spin text-muted-foreground" aria-hidden="true" />
</div>
)}
{/* Load more fallback button (D-14, TICK-06) */}
{hasMore && (
<div className="p-4">
<button
onClick={() => load(page + 1, true)}
type="button"
onClick={() => void loadMore()}
disabled={loadingMore}
className="w-full py-3 rounded-xl border text-sm font-medium hover:bg-accent transition-colors disabled:opacity-50"
aria-label="Load more tickets"
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50"
>
{loadingMore ? 'Loading…' : `Load more (${total - tickets.length} remaining)`}
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
</button>
</div>
)}