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