← back to Govarbitrage

apps/mobile/app/(tabs)/index.tsx

303 lines

/**
 * Opportunities screen — ranked list of active auction listings.
 * Dark financial-dashboard aesthetic. Sort control, pull-to-refresh,
 * graceful error/offline states.
 */
import { useRouter } from "expo-router";
import React, { useCallback, useEffect, useRef, useState } from "react";
import {
  ActivityIndicator,
  FlatList,
  Pressable,
  StyleSheet,
  Text,
  View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { OpportunityCard } from "../../components/OpportunityCard";
import { ErrorCard } from "../../components/ErrorCard";
import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
import { fetchListings } from "../../lib/api";
import type { ListingRow, SortField } from "../../lib/types";

type SortOption = {
  label: string;
  sort: SortField;
  dir: "asc" | "desc";
};

const SORT_OPTIONS: SortOption[] = [
  { label: "Opportunity", sort: "opportunityScore", dir: "desc" },
  { label: "ROI %", sort: "roi", dir: "desc" },
  { label: "Net Profit", sort: "netProfit", dir: "desc" },
  { label: "Closing Soon", sort: "closingAt", dir: "asc" },
  { label: "Newest", sort: "createdAt", dir: "desc" },
];

const PAGE_SIZE = 50;

export default function OpportunitiesScreen() {
  const router = useRouter();
  const [rows, setRows] = useState<ListingRow[]>([]);
  const [loading, setLoading] = useState(true);
  const [refreshing, setRefreshing] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [sortIdx, setSortIdx] = useState(0);
  const [total, setTotal] = useState(0);
  const [gated, setGated] = useState(false);
  const abortRef = useRef<AbortController | null>(null);

  const currentSort = SORT_OPTIONS[sortIdx];

  const load = useCallback(
    async (isRefresh = false) => {
      abortRef.current?.abort();
      const ctrl = new AbortController();
      abortRef.current = ctrl;

      if (!isRefresh) setLoading(true);
      setError(null);

      try {
        const data = await fetchListings(
          {
            sort: currentSort.sort,
            dir: currentSort.dir,
            pageSize: PAGE_SIZE,
            page: 1,
          },
          { signal: ctrl.signal }
        );
        setRows(data.rows);
        setTotal(data.total);
        setGated(data.gated);
      } catch (err: unknown) {
        if (err instanceof Error && err.name === "AbortError") return;
        const msg =
          err instanceof Error ? err.message : "Failed to load listings";
        setError(msg);
      } finally {
        // Skip state updates if this request was superseded (sort change) or unmounted.
        if (!ctrl.signal.aborted) {
          setLoading(false);
          setRefreshing(false);
        }
      }
    },
    [currentSort.sort, currentSort.dir]
  );

  useEffect(() => {
    load();
    return () => abortRef.current?.abort();
  }, [load]);

  const onRefresh = useCallback(() => {
    setRefreshing(true);
    load(true);
  }, [load]);

  const renderHeader = () => (
    <View>
      {/* Sort bar */}
      <View style={styles.sortBar}>
        <Text style={styles.sortLabel}>Sort:</Text>
        <FlatList
          horizontal
          data={SORT_OPTIONS}
          keyExtractor={(_, i) => String(i)}
          showsHorizontalScrollIndicator={false}
          contentContainerStyle={styles.sortChips}
          renderItem={({ item, index }) => (
            <Pressable
              style={[styles.sortChip, index === sortIdx && styles.sortChipActive]}
              onPress={() => setSortIdx(index)}
              accessibilityRole="button"
              accessibilityState={{ selected: index === sortIdx }}
              accessibilityLabel={`Sort by ${item.label}`}
            >
              <Text
                style={[styles.sortChipText, index === sortIdx && styles.sortChipTextActive]}
              >
                {item.label}
              </Text>
            </Pressable>
          )}
        />
      </View>

      {/* Stats bar */}
      {!loading && !error && (
        <View style={styles.statsBar}>
          <Text style={styles.statsText}>{total} active listings</Text>
          {gated && (
            <Text style={styles.gatedBadge}>FREE — upgrade for full data</Text>
          )}
        </View>
      )}
    </View>
  );

  if (loading && rows.length === 0) {
    return (
      <SafeAreaView style={styles.root} edges={["bottom"]}>
        <View style={styles.centerContainer}>
          <ActivityIndicator size="large" color={Colors.accent} />
          <Text style={styles.loadingText}>Loading opportunities...</Text>
        </View>
      </SafeAreaView>
    );
  }

  return (
    <SafeAreaView style={styles.root} edges={["bottom"]}>
      <FlatList
        data={rows}
        keyExtractor={(item) => item.id}
        renderItem={({ item, index }) => (
          <OpportunityCard
            row={item}
            rank={index + 1}
            onPress={() =>
              router.push({ pathname: "/listing/[id]", params: { id: item.id } })
            }
          />
        )}
        ListHeaderComponent={renderHeader}
        ListEmptyComponent={
          error ? (
            <ErrorCard message={error} onRetry={() => load()} />
          ) : (
            <View style={styles.emptyContainer}>
              <Text style={styles.emptyTitle}>No Opportunities Right Now</Text>
              <Text style={styles.emptyText}>
                Active government surplus auctions appear here as they&apos;re identified and
                scored. Pull to refresh or check back soon.
              </Text>
              <Pressable
                style={styles.emptyBtn}
                onPress={() => load()}
                accessibilityRole="button"
                accessibilityLabel="Refresh opportunities"
              >
                <Text style={styles.emptyBtnText}>Refresh</Text>
              </Pressable>
            </View>
          )
        }
        refreshing={refreshing}
        onRefresh={onRefresh}
        contentContainerStyle={styles.listContent}
        ItemSeparatorComponent={() => <View style={{ height: 0 }} />}
      />
    </SafeAreaView>
  );
}

const styles = StyleSheet.create({
  root: {
    flex: 1,
    backgroundColor: Colors.bg,
  },
  centerContainer: {
    flex: 1,
    alignItems: "center",
    justifyContent: "center",
    gap: Spacing.md,
  },
  loadingText: {
    color: Colors.textSecondary,
    fontSize: Typography.sizes.base,
  },
  sortBar: {
    flexDirection: "row",
    alignItems: "center",
    paddingVertical: Spacing.sm,
    paddingLeft: Spacing.lg,
    borderBottomWidth: 1,
    borderBottomColor: Colors.border,
  },
  sortLabel: {
    fontSize: Typography.sizes.xs,
    color: Colors.textMuted,
    textTransform: "uppercase",
    letterSpacing: 0.5,
    marginRight: Spacing.sm,
  },
  sortChips: {
    gap: Spacing.sm,
    paddingRight: Spacing.lg,
  },
  sortChip: {
    paddingHorizontal: Spacing.md,
    paddingVertical: Spacing.xs,
    borderRadius: Radius.pill,
    backgroundColor: Colors.surfaceAlt,
    borderWidth: 1,
    borderColor: Colors.border,
  },
  sortChipActive: {
    backgroundColor: Colors.accent + "22",
    borderColor: Colors.accent,
  },
  sortChipText: {
    fontSize: Typography.sizes.xs,
    color: Colors.textSecondary,
    fontWeight: "600",
  },
  sortChipTextActive: {
    color: Colors.accent,
  },
  statsBar: {
    flexDirection: "row",
    alignItems: "center",
    paddingHorizontal: Spacing.lg,
    paddingVertical: Spacing.sm,
    gap: Spacing.md,
  },
  statsText: {
    fontSize: Typography.sizes.xs,
    color: Colors.textMuted,
  },
  gatedBadge: {
    fontSize: Typography.sizes.xs,
    color: Colors.warning,
    fontWeight: "600",
  },
  listContent: {
    paddingTop: Spacing.sm,
    paddingBottom: Spacing.xl,
  },
  emptyContainer: {
    padding: Spacing.xxl,
    alignItems: "center",
    gap: Spacing.sm,
  },
  emptyTitle: {
    color: Colors.textPrimary,
    fontSize: Typography.sizes.lg,
    fontWeight: "700",
    textAlign: "center",
  },
  emptyText: {
    color: Colors.textSecondary,
    fontSize: Typography.sizes.base,
    textAlign: "center",
    lineHeight: 20,
  },
  emptyBtn: {
    marginTop: Spacing.md,
    paddingHorizontal: Spacing.xl,
    paddingVertical: Spacing.md,
    borderRadius: Radius.pill,
    backgroundColor: Colors.accent,
    minHeight: 44,
    justifyContent: "center",
  },
  emptyBtnText: {
    color: Colors.textPrimary,
    fontSize: Typography.sizes.base,
    fontWeight: "700",
  },
});