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