← 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,
  },
});