- Replaces 'coming soon' placeholder with full read-only feed - useState/useEffect/fetch only (no SWR/react-query per CLAUDE.md D-38) - IntersectionObserver sentinel with rootMargin 200px for auto-load - Load more fallback button with aria-label, min-h-[44px] touch target - 5 AnalyzerRowSkeleton instances on initial load (D-28) - Empty state with dashed border, Sparkles icon, Open desktop Analyzer link - toast.error on load failures; Load more flips to Retry on error - No edit/re-run/prompt-tuning controls (ANL-05)
151 lines
5.7 KiB
TypeScript
151 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
/* MobileAnalyzerPage — phase 06 (ANL-01, ANL-02, ANL-05, ANL-06).
|
|
* Purpose: Read-only Analyzer feed — most-recent-first list of completed AI ticket analyses.
|
|
* IntersectionObserver infinite scroll + Load more fallback button.
|
|
* No edit, re-run, or prompt-tuning controls (ANL-05). Per D-08..D-09, D-28..D-31, D-34..D-35, D-38. */
|
|
|
|
import { useEffect, useState, useCallback, useRef } from 'react';
|
|
import { Loader2, Sparkles, ExternalLink } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { AnalyzerFeedRow } from '@/components/mobile/AnalyzerFeedRow';
|
|
import { AnalyzerRowSkeleton } from '@/components/mobile/AnalyzerRowSkeleton';
|
|
import type { AnalyzerFeedRow as AnalyzerFeedRowType, AnalyzerFeedResponse } from '@/app/api/mobile/analyzer/feed/route';
|
|
|
|
export default function MobileAnalyzerPage() {
|
|
// List state
|
|
const [analyses, setAnalyses] = useState<AnalyzerFeedRowType[]>([]);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
|
const [hasMore, setHasMore] = useState(false);
|
|
const [loading, setLoading] = useState(true);
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
// First page (mount)
|
|
const loadFirst = useCallback(async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const r = await fetch('/api/mobile/analyzer/feed?limit=25');
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: AnalyzerFeedResponse = await r.json();
|
|
setAnalyses(data.analyses);
|
|
setNextCursor(data.nextCursor);
|
|
setHasMore(data.hasMore);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : 'Failed to load analyses';
|
|
setError(msg);
|
|
toast.error('Failed to load analyses');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, []);
|
|
|
|
// Cursor advance
|
|
const loadMore = useCallback(async () => {
|
|
if (loadingMore || !hasMore || !nextCursor) return;
|
|
setLoadingMore(true);
|
|
setError(null);
|
|
try {
|
|
const sp = new URLSearchParams({ cursor: nextCursor, limit: '25' });
|
|
const r = await fetch(`/api/mobile/analyzer/feed?${sp.toString()}`);
|
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
const data: AnalyzerFeedResponse = await r.json();
|
|
setAnalyses(prev => [...prev, ...data.analyses]);
|
|
setNextCursor(data.nextCursor);
|
|
setHasMore(data.hasMore);
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : 'Failed to load more analyses';
|
|
setError(msg);
|
|
toast.error('Failed to load more analyses');
|
|
} finally {
|
|
setLoadingMore(false);
|
|
}
|
|
}, [loadingMore, hasMore, nextCursor]);
|
|
|
|
useEffect(() => { void loadFirst(); }, [loadFirst]);
|
|
|
|
// IntersectionObserver — D-08 (rootMargin: '200px', no-op when loadingMore || !hasMore)
|
|
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]);
|
|
|
|
// ──── Render ────
|
|
return (
|
|
<div className="px-4 py-4 space-y-4">
|
|
{/* Page H1 — D-35 (in body, not in shell HeaderBar) */}
|
|
<h1 className="text-base font-semibold">Analyzer</h1>
|
|
|
|
{loading ? (
|
|
<div className="space-y-3">
|
|
{Array.from({ length: 5 }).map((_, i) => <AnalyzerRowSkeleton key={i} />)}
|
|
</div>
|
|
) : analyses.length === 0 ? (
|
|
// Empty state — D-31
|
|
<div className="py-12">
|
|
<div className="flex flex-col items-center justify-center text-center gap-3 rounded-md border border-dashed border-border/60 px-6 py-10">
|
|
<span className="flex h-10 w-10 items-center justify-center rounded-md bg-muted text-muted-foreground">
|
|
<Sparkles className="h-5 w-5" />
|
|
</span>
|
|
<p className="text-sm font-semibold text-foreground">No analyses yet</p>
|
|
<p className="text-sm text-muted-foreground max-w-prose">
|
|
Completed AI ticket analyses will appear here.
|
|
</p>
|
|
<a
|
|
href="/analyzer/tickets"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1.5 text-sm font-semibold text-primary hover:underline mt-2 min-h-[44px]"
|
|
>
|
|
Open desktop Analyzer
|
|
<ExternalLink className="h-4 w-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-3">
|
|
{analyses.map((row) => (
|
|
<AnalyzerFeedRow key={row.id} row={row} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Sentinel — D-08 */}
|
|
<div ref={sentinelRef} aria-hidden="true" />
|
|
|
|
{/* Loading-more spinner — D-29 */}
|
|
{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-09, ANL accessibility */}
|
|
{hasMore && (
|
|
<button
|
|
type="button"
|
|
onClick={() => void loadMore()}
|
|
disabled={loadingMore}
|
|
aria-label="Load more analyses"
|
|
className="w-full py-3 rounded-xl border text-sm font-semibold hover:bg-muted/50 transition-colors disabled:opacity-50 min-h-[44px]"
|
|
>
|
|
{error ? 'Retry' : loadingMore ? 'Loading…' : 'Load more'}
|
|
</button>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|