← back to Govarbitrage
apps/mobile/app/listing/[id].tsx
680 lines
/**
* Listing detail screen — full Valuation table, Cost Breakdown & Profit,
* Scores grid, Recommended Max Bid callout, risk + drop-ship badges,
* and link out to the original auction URL.
*/
import { useLocalSearchParams, useNavigation } from "expo-router";
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import {
ActivityIndicator,
Image,
Linking,
Platform,
Pressable,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { ScoreBadge } from "../../components/ScoreBadge";
import { ErrorCard } from "../../components/ErrorCard";
import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
import { fetchListing } from "../../lib/api";
import {
closingCountdown,
conditionLabel,
dropShipLabel,
fmtDateTime,
fmtPct,
fmtScore,
fmtUSD,
isClosingSoon,
sourceLabel,
} from "../../lib/format";
import type { ListingDetail, Score } from "../../lib/types";
import { pnlColor } from "../../lib/pnl";
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionHeader({ title }: { title: string }) {
return (
<View style={styles.sectionHeader}>
<Text style={styles.sectionHeaderText}>{title}</Text>
</View>
);
}
function TableRow({
label,
value,
valueColor,
mono = false,
}: {
label: string;
value: string;
valueColor?: string;
mono?: boolean;
}) {
return (
<View style={styles.tableRow}>
<Text style={styles.tableLabel}>{label}</Text>
<Text
style={[
styles.tableValue,
valueColor ? { color: valueColor } : undefined,
mono ? styles.tableMono : undefined,
]}
>
{value}
</Text>
</View>
);
}
function riskColor(risk: string) {
if (risk === "LOW") return Colors.riskLow;
if (risk === "HIGH") return Colors.riskHigh;
return Colors.riskMedium;
}
// Horizontal image strip for the hero. Broken URLs drop out silently (onError),
// so a dead image never leaves a gray box on the screen.
function HeroImageStrip({ urls }: { urls: string[] }) {
const [failed, setFailed] = useState<Set<string>>(new Set());
const visible = urls.filter((u) => u && !failed.has(u));
if (visible.length === 0) return null;
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.imageStrip}
accessibilityLabel="Listing photos"
>
{visible.map((u) => (
<Image
key={u}
source={{ uri: u }}
style={styles.heroImage}
resizeMode="cover"
onError={() =>
setFailed((prev) => {
const next = new Set(prev);
next.add(u);
return next;
})
}
/>
))}
</ScrollView>
);
}
// ── Main screen ───────────────────────────────────────────────────────────────
export default function ListingDetailScreen() {
const { id } = useLocalSearchParams<{ id: string }>();
const navigation = useNavigation();
const [detail, setDetail] = useState<ListingDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
const load = useCallback(async () => {
if (!id) return;
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoading(true);
setError(null);
try {
const data = await fetchListing(id, { signal: ctrl.signal });
setDetail(data);
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") return;
setError(err instanceof Error ? err.message : "Failed to load listing");
} finally {
// Skip the state update if this request was superseded/unmounted.
if (!ctrl.signal.aborted) setLoading(false);
}
}, [id]);
useEffect(() => {
load();
return () => abortRef.current?.abort();
}, [load]);
// Update nav title once we have the detail
useLayoutEffect(() => {
if (detail?.title) {
navigation.setOptions({ title: detail.title.slice(0, 40) });
}
}, [detail, navigation]);
if (loading) {
return (
<SafeAreaView style={styles.root} edges={["bottom"]}>
<View style={styles.center}>
<ActivityIndicator size="large" color={Colors.accent} />
</View>
</SafeAreaView>
);
}
if (error || !detail) {
return (
<SafeAreaView style={styles.root} edges={["bottom"]}>
<ErrorCard message={error ?? "Listing not found"} onRetry={load} />
</SafeAreaView>
);
}
const cb = detail.costBreakdown;
const r = detail.research;
const primaryScore: Score | undefined = detail.scores[0];
const countdown = closingCountdown(detail.closingAt);
const soon = isClosingSoon(detail.closingAt);
return (
<SafeAreaView style={styles.root} edges={["bottom"]}>
<ScrollView contentContainerStyle={styles.content}>
{/* ── Hero block ─────────────────────────────────────────────────── */}
<View style={styles.heroCard}>
<View style={styles.heroTopRow}>
<View style={styles.sourceChip}>
<Text style={styles.sourceChipText}>{sourceLabel(detail.source)}</Text>
</View>
<Text style={styles.lotLabel}>Lot #{detail.sourceAuctionId}</Text>
<View style={styles.spacer} />
<Text style={[styles.countdown, soon && styles.countdownUrgent]}>
{countdown}
</Text>
</View>
<Text style={styles.heroTitle}>{detail.title}</Text>
{/* Location + condition */}
<View style={styles.heroMeta}>
{detail.locationCity && (
<Text style={styles.heroMetaItem}>
{detail.locationCity}
{detail.locationState ? `, ${detail.locationState}` : ""}
</Text>
)}
<Text style={styles.heroMetaItem}>{conditionLabel(detail.condition)}</Text>
{detail.quantity > 1 && (
<Text style={styles.heroMetaItem}>Qty: {detail.quantity}</Text>
)}
</View>
{/* Risk + dropship badges */}
{primaryScore && (
<View style={styles.badgeRow}>
<View
style={[styles.riskBadge, { backgroundColor: riskColor(primaryScore.risk) + "22", borderColor: riskColor(primaryScore.risk) }]}
>
<Text style={[styles.riskBadgeText, { color: riskColor(primaryScore.risk) }]}>
{primaryScore.risk} RISK
</Text>
</View>
<View style={styles.dropShipBadge}>
<Text style={styles.dropShipText}>{dropShipLabel(primaryScore.dropShip)}</Text>
</View>
</View>
)}
{/* Admin timestamp — Steve's hard rule */}
<Text style={styles.createdAt} accessibilityLabel={`Imported ${detail.createdAt}`}>
Imported {fmtDateTime(detail.createdAt)}
</Text>
</View>
{/* ── Listing photos (broken URLs drop out silently) ─────────────── */}
{detail.imageUrls && detail.imageUrls.length > 0 && (
<HeroImageStrip urls={detail.imageUrls} />
)}
{/* ── Recommended Max Bid CALLOUT ────────────────────────────────── */}
{cb && (
<View style={styles.maxBidCallout}>
<Text style={styles.maxBidLabel}>Recommended Max Bid</Text>
<Text style={styles.maxBidValue}>{fmtUSD(cb.recommendedMaxBid)}</Text>
<Text style={styles.maxBidSub}>Current bid: {fmtUSD(detail.currentBid)} ({detail.bidCount} bids)</Text>
</View>
)}
{/* ── External link ──────────────────────────────────────────────── */}
{detail.sourceUrl && (
<Pressable
style={styles.linkBtn}
onPress={() => Linking.openURL(detail.sourceUrl!)}
accessibilityRole="link"
accessibilityLabel={`View this listing on ${sourceLabel(detail.source)}`}
>
<Text style={styles.linkBtnText}>View on {sourceLabel(detail.source)} →</Text>
</Pressable>
)}
{/* ── Valuation block ────────────────────────────────────────────── */}
{r ? (
<>
<SectionHeader title="Valuation" />
<View style={styles.card}>
<TableRow label="New Retail" value={fmtUSD(r.newRetail)} />
<TableRow label="Avg Retail" value={fmtUSD(r.avgRetail)} />
<TableRow label="Used — Low" value={fmtUSD(r.usedLow)} />
<TableRow label="Used — Avg" value={fmtUSD(r.usedSoldPrice)} />
<TableRow label="Used — High" value={fmtUSD(r.usedHigh)} />
<TableRow label="Wholesale" value={fmtUSD(r.wholesaleValue)} />
<TableRow label="Liquidation" value={fmtUSD(r.liquidationValue)} />
<TableRow label="Sell Today" value={fmtUSD(r.sellTodayValue)} />
<TableRow label="Expected Sale" value={fmtUSD(r.expectedSalePrice)} valueColor={Colors.profit} />
<TableRow
label="Prob. of Sale"
value={fmtPct(r.probabilityOfSale, { decimals: 0 })}
/>
<TableRow
label="Days to Sell"
value={r.daysUntilSold != null && Number.isFinite(r.daysUntilSold) ? `${r.daysUntilSold}d` : "—"}
/>
<TableRow
label="Confidence"
value={fmtScore(r.confidenceScore)}
/>
</View>
</>
) : (
detail.gated && (
<View style={styles.gatedBlock}>
<Text style={styles.gatedText}>Valuation data requires a paid tier.</Text>
</View>
)
)}
{/* ── Cost Breakdown & Profit ────────────────────────────────────── */}
{cb ? (
<>
<SectionHeader title="Cost Breakdown & Profit" />
<View style={styles.card}>
<TableRow label="Winning Bid" value={fmtUSD(cb.winningBid)} mono />
<TableRow label="Buyer Premium" value={fmtUSD(cb.buyerPremium)} mono />
<TableRow label="Sales Tax" value={fmtUSD(cb.salesTax)} mono />
<TableRow label="Shipping" value={fmtUSD(cb.shipping)} mono />
<TableRow label="Freight" value={fmtUSD(cb.freight)} mono />
<TableRow label="Repairs" value={fmtUSD(cb.repairs)} mono />
<TableRow label="Marketplace Fees" value={fmtUSD(cb.marketplaceFees)} mono />
<View style={styles.divider} />
<TableRow label="Total Investment" value={fmtUSD(cb.totalInvestment)} valueColor={Colors.warning} />
<TableRow label="Expected Returns" value={fmtUSD(cb.expectedReturns)} valueColor={Colors.profit} />
<TableRow
label="Net Profit"
value={fmtUSD(cb.expectedNetProfit)}
valueColor={pnlColor(cb.expectedNetProfit)}
/>
<TableRow
label="ROI"
value={fmtPct(cb.roi)}
valueColor={pnlColor(cb.roi)}
/>
<TableRow
label="Annualized Return"
value={fmtPct(cb.annualizedReturn)}
/>
</View>
</>
) : (
detail.gated && (
<View style={styles.gatedBlock}>
<Text style={styles.gatedText}>Cost breakdown requires a paid tier.</Text>
</View>
)
)}
{/* ── Scores grid ───────────────────────────────────────────────── */}
{detail.scores.length > 0 && (
<>
<SectionHeader title="Scores" />
{detail.scores.map((sc) => (
<View key={sc.id} style={styles.card}>
<Text style={styles.scoreProfileLabel}>{sc.profile.replace(/_/g, " ")}</Text>
<View style={styles.scoresGrid}>
<ScoreBadge label="Opportunity" score={sc.value} size="lg" />
<ScoreBadge label="Arbitrage" score={sc.arbitrage} />
<ScoreBadge label="Demand" score={sc.demand} />
<ScoreBadge label="Velocity" score={sc.velocity} />
<ScoreBadge label="Logistics" score={sc.logistics} />
<ScoreBadge label="Condition" score={sc.condition} />
<ScoreBadge label="Competition" score={sc.competition} />
<ScoreBadge label="Buyer" score={sc.buyer} />
</View>
{sc.explanation ? (
<Text style={styles.scoreExplanation}>{sc.explanation}</Text>
) : null}
</View>
))}
</>
)}
{/* ── Description ───────────────────────────────────────────────── */}
{detail.description && (
<>
<SectionHeader title="Description" />
<View style={styles.card}>
<Text style={styles.description}>{detail.description}</Text>
</View>
</>
)}
{/* ── Comparables ──────────────────────────────────────────────── */}
{detail.comparables.length > 0 && (
<>
<SectionHeader title={`Comparables (${detail.comparables.length})`} />
<View style={styles.card}>
{detail.comparables.map((comp) => (
<View key={comp.id} style={styles.tableRow}>
<View style={{ flex: 1 }}>
<Text style={styles.compTitle} numberOfLines={1}>{comp.title}</Text>
<Text style={styles.compMeta}>
{comp.kind} · {comp.source ?? "unknown"}
{comp.soldAt ? ` · ${fmtDateTime(comp.soldAt)}` : ""}
</Text>
</View>
<Text style={[styles.tableValue, { color: Colors.profit }]}>
{fmtUSD(comp.price)}
</Text>
</View>
))}
</View>
</>
)}
</ScrollView>
</SafeAreaView>
);
}
// ── Styles ────────────────────────────────────────────────────────────────────
const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: Colors.bg,
},
center: {
flex: 1,
alignItems: "center",
justifyContent: "center",
},
content: {
paddingBottom: Spacing.xxl,
gap: Spacing.xs,
},
// Hero
heroCard: {
backgroundColor: Colors.surface,
borderBottomWidth: 1,
borderBottomColor: Colors.border,
padding: Spacing.lg,
gap: Spacing.sm,
},
heroTopRow: {
flexDirection: "row",
alignItems: "center",
gap: Spacing.sm,
},
sourceChip: {
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
backgroundColor: Colors.accent + "22",
borderRadius: Radius.pill,
borderWidth: 1,
borderColor: Colors.accent,
},
sourceChipText: {
fontSize: Typography.sizes.xs,
color: Colors.accent,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
lotLabel: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
},
spacer: { flex: 1 },
countdown: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
fontWeight: "700",
fontVariant: ["tabular-nums"],
},
countdownUrgent: {
color: Colors.warning,
},
heroTitle: {
fontSize: Typography.sizes.lg,
color: Colors.textPrimary,
fontWeight: "700",
lineHeight: 26,
},
heroMeta: {
flexDirection: "row",
flexWrap: "wrap",
gap: Spacing.sm,
},
heroMetaItem: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
},
badgeRow: {
flexDirection: "row",
gap: Spacing.sm,
flexWrap: "wrap",
},
riskBadge: {
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
borderRadius: Radius.sm,
borderWidth: 1,
},
riskBadgeText: {
fontSize: Typography.sizes.xs,
fontWeight: "700",
letterSpacing: 0.5,
},
dropShipBadge: {
paddingHorizontal: Spacing.sm,
paddingVertical: Spacing.xs,
borderRadius: Radius.sm,
backgroundColor: Colors.surfaceAlt,
borderWidth: 1,
borderColor: Colors.border,
},
dropShipText: {
fontSize: Typography.sizes.xs,
color: Colors.textSecondary,
fontWeight: "600",
},
createdAt: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
marginTop: Spacing.xs,
},
// Hero image strip
imageStrip: {
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.md,
gap: Spacing.sm,
},
heroImage: {
width: 280,
height: 200,
borderRadius: Radius.lg,
backgroundColor: Colors.surface,
},
// Max bid callout
maxBidCallout: {
backgroundColor: Colors.accent + "18",
borderWidth: 1,
borderColor: Colors.accent,
borderRadius: Radius.lg,
margin: Spacing.lg,
padding: Spacing.lg,
alignItems: "center",
gap: Spacing.xs,
},
maxBidLabel: {
fontSize: Typography.sizes.xs,
color: Colors.accent,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 1,
},
maxBidValue: {
fontSize: Typography.sizes.xxl,
color: Colors.textPrimary,
fontWeight: "700",
fontVariant: ["tabular-nums"],
},
maxBidSub: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
},
// External link
linkBtn: {
marginHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
borderRadius: Radius.md,
borderWidth: 1,
borderColor: Colors.accent,
alignItems: "center",
},
linkBtnText: {
fontSize: Typography.sizes.base,
color: Colors.accent,
fontWeight: "600",
},
// Section headers
sectionHeader: {
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.md,
paddingBottom: Spacing.xs,
},
sectionHeaderText: {
fontSize: Typography.sizes.xs,
fontWeight: "700",
color: Colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1,
},
// Cards / tables
card: {
backgroundColor: Colors.surface,
borderTopWidth: 1,
borderBottomWidth: 1,
borderColor: Colors.border,
paddingVertical: Spacing.xs,
},
tableRow: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: Colors.border,
},
tableLabel: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
flex: 1,
},
tableValue: {
fontSize: Typography.sizes.sm,
color: Colors.textPrimary,
fontWeight: "600",
fontVariant: ["tabular-nums"],
textAlign: "right",
},
tableMono: {
fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
},
divider: {
height: 1,
backgroundColor: Colors.borderStrong,
marginVertical: Spacing.xs,
marginHorizontal: Spacing.lg,
},
// Scores
scoreProfileLabel: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1,
paddingHorizontal: Spacing.lg,
paddingTop: Spacing.md,
paddingBottom: Spacing.sm,
},
scoresGrid: {
flexDirection: "row",
flexWrap: "wrap",
paddingHorizontal: Spacing.lg,
paddingBottom: Spacing.md,
gap: Spacing.xl,
},
scoreExplanation: {
fontSize: Typography.sizes.xs,
color: Colors.textSecondary,
lineHeight: 16,
paddingHorizontal: Spacing.lg,
paddingBottom: Spacing.md,
fontStyle: "italic",
},
// Description
description: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
lineHeight: 20,
paddingHorizontal: Spacing.lg,
paddingVertical: Spacing.md,
},
// Comparables
compTitle: {
fontSize: Typography.sizes.sm,
color: Colors.textPrimary,
fontWeight: "500",
},
compMeta: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
},
// Gated
gatedBlock: {
marginHorizontal: Spacing.lg,
padding: Spacing.lg,
backgroundColor: Colors.surface,
borderRadius: Radius.md,
borderWidth: 1,
borderColor: Colors.border,
alignItems: "center",
},
gatedText: {
fontSize: Typography.sizes.sm,
color: Colors.textMuted,
fontStyle: "italic",
},
});