← back to Govarbitrage
apps/mobile/components/OpportunityCard.tsx
221 lines
import React from "react";
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { Colors, Radius, Spacing, Typography } from "../constants/theme";
import { closingCountdown, fmtDateTime, fmtPct, fmtScore, fmtUSD, isClosingSoon, sourceLabel } from "../lib/format";
import type { ListingRow } from "../lib/types";
import { pnlColor } from "../lib/pnl";
interface Props {
row: ListingRow;
rank: number;
onPress: () => void;
}
function sourceChipColor(source: string): string {
if (source === "GSA_AUCTIONS") return Colors.gsaColor;
return Colors.govdealsColor;
}
function riskColor(risk: string): string {
if (risk === "LOW") return Colors.riskLow;
if (risk === "HIGH") return Colors.riskHigh;
return Colors.riskMedium;
}
export function OpportunityCard({ row, rank, onPress }: Props) {
const countdown = closingCountdown(row.closingAt);
const soon = isClosingSoon(row.closingAt);
const chipColor = sourceChipColor(row.source);
return (
<TouchableOpacity
style={styles.card}
onPress={onPress}
activeOpacity={0.8}
accessible
accessibilityRole="button"
accessibilityLabel={`${row.title}. Rank ${rank}${
row.opportunityScore != null ? `, opportunity score ${fmtScore(row.opportunityScore)}` : ""
}. Current bid ${fmtUSD(row.currentBid)}. ${countdown}.`}
accessibilityHint="Opens the full listing detail"
>
{/* Header row: rank + source chip + countdown */}
<View style={styles.headerRow}>
<View style={styles.rankBadge}>
<Text style={styles.rankText}>#{rank}</Text>
</View>
<View style={[styles.chip, { backgroundColor: chipColor + "22", borderColor: chipColor }]}>
<Text style={[styles.chipText, { color: chipColor }]}>{sourceLabel(row.source)}</Text>
</View>
<View style={styles.spacer} />
<Text style={[styles.countdown, soon && styles.countdownUrgent]}>{countdown}</Text>
</View>
{/* Title */}
<Text style={styles.title} numberOfLines={2}>{row.title}</Text>
{/* Category + location row */}
<View style={styles.metaRow}>
{row.category ? (
<Text style={styles.metaItem}>{row.category}</Text>
) : null}
{row.locationCity ? (
<Text style={styles.metaItem}>
{row.locationCity}
{row.locationState ? `, ${row.locationState}` : ""}
</Text>
) : null}
<Text style={[styles.riskBadge, { color: riskColor(row.risk) }]}>
{row.risk} RISK
</Text>
</View>
{/* Financial summary row */}
<View style={styles.financialRow}>
<View style={styles.financialItem}>
<Text style={styles.financialLabel}>Current Bid</Text>
<Text style={styles.financialValue}>{fmtUSD(row.currentBid)}</Text>
</View>
{row.netProfit != null && (
<View style={styles.financialItem}>
<Text style={styles.financialLabel}>Net Profit</Text>
<Text style={[styles.financialValue, { color: pnlColor(row.netProfit) }]}>
{fmtUSD(row.netProfit)}
</Text>
</View>
)}
{row.roi != null && (
<View style={styles.financialItem}>
<Text style={styles.financialLabel}>ROI</Text>
<Text style={[styles.financialValue, { color: pnlColor(row.roi) }]}>
{fmtPct(row.roi)}
</Text>
</View>
)}
{row.opportunityScore != null && (
<View style={styles.financialItem}>
<Text style={styles.financialLabel}>Score</Text>
<Text style={styles.scoreValue}>{fmtScore(row.opportunityScore)}</Text>
</View>
)}
</View>
{/* Admin timestamp (Steve's hard rule: created date+time visible on card) */}
{row.createdAt && (
<Text style={styles.createdAt} accessibilityLabel={`Imported ${row.createdAt}`}>
Imported {fmtDateTime(row.createdAt)}
</Text>
)}
</TouchableOpacity>
);
}
const styles = StyleSheet.create({
card: {
backgroundColor: Colors.surface,
borderRadius: Radius.lg,
borderWidth: 1,
borderColor: Colors.border,
padding: Spacing.md,
marginHorizontal: Spacing.lg,
marginVertical: Spacing.xs,
gap: Spacing.sm,
},
headerRow: {
flexDirection: "row",
alignItems: "center",
gap: Spacing.sm,
},
rankBadge: {
width: 28,
height: 28,
borderRadius: Radius.sm,
backgroundColor: Colors.rankBg,
borderWidth: 1,
borderColor: Colors.rankGold,
alignItems: "center",
justifyContent: "center",
},
rankText: {
fontSize: Typography.sizes.xs,
fontWeight: "700",
color: Colors.rankGold,
},
chip: {
paddingHorizontal: Spacing.sm,
paddingVertical: 2,
borderRadius: Radius.pill,
borderWidth: 1,
},
chipText: {
fontSize: Typography.sizes.xs,
fontWeight: "600",
textTransform: "uppercase",
letterSpacing: 0.5,
},
spacer: { flex: 1 },
countdown: {
fontSize: Typography.sizes.sm,
color: Colors.textSecondary,
fontVariant: ["tabular-nums"],
fontWeight: "600",
},
countdownUrgent: {
color: Colors.warning,
},
title: {
fontSize: Typography.sizes.base,
color: Colors.textPrimary,
fontWeight: "600",
lineHeight: 20,
},
metaRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: Spacing.sm,
alignItems: "center",
},
metaItem: {
fontSize: Typography.sizes.xs,
color: Colors.textSecondary,
},
riskBadge: {
fontSize: 10,
fontWeight: "700",
letterSpacing: 0.5,
},
financialRow: {
flexDirection: "row",
gap: Spacing.lg,
paddingTop: Spacing.xs,
borderTopWidth: 1,
borderTopColor: Colors.border,
},
financialItem: {
gap: 2,
},
financialLabel: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
textTransform: "uppercase",
letterSpacing: 0.5,
},
financialValue: {
fontSize: Typography.sizes.md,
fontWeight: "700",
color: Colors.textPrimary,
fontVariant: ["tabular-nums"],
},
scoreValue: {
fontSize: Typography.sizes.md,
fontWeight: "700",
color: Colors.accent,
fontVariant: ["tabular-nums"],
},
createdAt: {
fontSize: Typography.sizes.xs,
color: Colors.textMuted,
marginTop: Spacing.xs,
},
});