diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 15582d6..ad7c49c 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -1,733 +1,1420 @@ -// @ts-nocheck -import AsyncStorage from "@react-native-async-storage/async-storage"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { - FlatList, - Pressable, - RefreshControl, - StyleSheet, - Text, - TextInput, - View, -} from "react-native"; -import { SafeAreaView } from "react-native-safe-area-context"; -import { StatusBar } from "expo-status-bar"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { Ionicons } from "@expo/vector-icons"; - -import { - fetchCatalog, - fetchRegistryStatus, - getApiBaseUrl, -} from "../api/resources"; -import { ErrorBanner } from "../components/ErrorBanner"; -import { ResourceCard } from "../components/ResourceCard"; -import { SkeletonCard } from "../components/SkeletonCard"; -import type { RootStackParamList } from "../navigation"; -import type { CatalogFilters, Resource, VerificationStatus } from "../types"; -import { spacing } from "../theme"; -import type { ThemeColors } from "../theme"; -import { useAppTheme } from "../theme/ThemeProvider"; - -const CATALOG_FILTERS_KEY = "@mindvault_catalog_filters"; -const SEARCH_DEBOUNCE_MS = 300; - -interface PersistedFilters { - search: string; - verification: VerificationFilter; - resourceType: ResourceTypeFilter; - minPrice: string; - maxPrice: string; -} - -interface CatalogScreenProps { - navigation: NativeStackNavigationProp; -} - -type VerificationFilter = "all" | VerificationStatus; -type ResourceTypeFilter = "all" | "file" | "link"; - -const DEFAULT_VERIFICATION: VerificationFilter = "all"; -const DEFAULT_RESOURCE_TYPE: ResourceTypeFilter = "all"; - -const VERIFICATION_OPTIONS: { value: VerificationFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "verified", label: "Verified" }, - { value: "pending", label: "Pending" }, - { value: "rejected", label: "Rejected" }, -]; - -const RESOURCE_TYPE_OPTIONS: { value: ResourceTypeFilter; label: string }[] = [ - { value: "all", label: "All" }, - { value: "file", label: "File" }, - { value: "link", label: "Link" }, -]; - -type SortBy = "newest" | "title" | "price"; - -const SORT_OPTIONS: { value: SortBy; label: string }[] = [ - { value: "newest", label: "Newest" }, - { value: "title", label: "Title" }, - { value: "price", label: "Price" }, -]; - -function formatLastUpdated(value: Date): string { - return value.toLocaleString([], { - dateStyle: "medium", - timeStyle: "short", - }); -} - -function createStyles(colors: ThemeColors) { - return StyleSheet.create({ - listContent: { - padding: spacing.lg, - paddingBottom: spacing.xxl, - gap: spacing.md, - }, - header: { - gap: spacing.md, - marginBottom: spacing.lg, - }, - headerRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "flex-start", - gap: spacing.md, - }, - settingsButton: { - borderRadius: 10, - backgroundColor: colors.neutralBg, - paddingHorizontal: 12, - paddingVertical: 8, - minHeight: 44, - minWidth: 44, - justifyContent: "center", - alignItems: "center", - }, - registry: { - marginTop: spacing.xs, - fontSize: 13, - fontWeight: "600", - color: colors.primary, - }, - lastUpdated: { - fontSize: 12, - color: colors.textSubtle, - }, - searchInput: { - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 12, - fontSize: 15, - color: colors.text, - }, - filters: { - gap: spacing.md, - }, - filterGroup: { - gap: spacing.sm, - }, - filterLabel: { - fontSize: 12, - fontWeight: "600", - textTransform: "uppercase", - letterSpacing: 0.5, - color: colors.textMuted, - }, - chipRow: { - flexDirection: "row", - flexWrap: "wrap", - gap: spacing.sm, - }, - chip: { - borderRadius: 999, - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - paddingHorizontal: 14, - paddingVertical: 8, - }, - chipActive: { - borderColor: colors.primary, - backgroundColor: colors.primaryMuted, - }, - chipText: { - fontSize: 13, - fontWeight: "500", - color: colors.textMuted, - }, - chipTextActive: { - color: colors.primary, - fontWeight: "600", - }, - priceRow: { - flexDirection: "row", - gap: spacing.md, - }, - priceField: { - flex: 1, - gap: spacing.xs, - }, - priceInput: { - borderWidth: 1, - borderColor: colors.border, - backgroundColor: colors.surface, - borderRadius: 12, - paddingHorizontal: 14, - paddingVertical: 10, - fontSize: 15, - color: colors.text, - }, - resultsRow: { - flexDirection: "row", - justifyContent: "space-between", - alignItems: "center", - gap: spacing.md, - }, - resultsCount: { - flex: 1, - fontSize: 13, - color: colors.textMuted, - }, - clearButton: { - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 10, - backgroundColor: colors.neutralBg, - }, - clearButtonDisabled: { - opacity: 0.5, - }, - clearButtonText: { - fontSize: 13, - fontWeight: "600", - color: colors.primary, - }, - apiHint: { - fontSize: 11, - color: colors.textSubtle, - }, - skeletons: { - gap: 12, - }, - separator: { - height: spacing.md, - }, - emptyState: { - alignItems: "center", - paddingVertical: spacing.xxl, - gap: spacing.sm, - }, - emptyTitle: { - fontSize: 18, - fontWeight: "600", - color: colors.text, - }, - emptyBody: { - textAlign: "center", - fontSize: 14, - color: colors.textMuted, - maxWidth: 320, - lineHeight: 20, - }, - fab: { - position: "absolute", - right: spacing.lg, - bottom: spacing.xl, - backgroundColor: colors.primary, - borderRadius: 28, - paddingHorizontal: 20, - paddingVertical: 14, - elevation: 4, - shadowColor: "#000", - shadowOffset: { width: 0, height: 2 }, - shadowOpacity: 0.2, - shadowRadius: 4, - }, - fabText: { - color: "#ffffff", - fontWeight: "700", - fontSize: 15, - }, - toast: { - position: "absolute", - bottom: spacing.xl, - left: spacing.lg, - right: spacing.lg, - backgroundColor: colors.text, - borderRadius: 12, - paddingVertical: 12, - paddingHorizontal: 16, - }, - toastText: { - color: colors.background, - textAlign: "center", - fontSize: 14, - fontWeight: "500", - }, - }); -} - -export function CatalogScreen({ navigation }: CatalogScreenProps) { - const { colors, shared, typography, colorScheme } = useAppTheme(); - const styles = useMemo(() => createStyles(colors), [colors]); - const filtersRestored = useRef(false); - - const [resources, setResources] = useState([]); - const [registryCount, setRegistryCount] = useState(null); - const [search, setSearch] = useState(""); - const [debouncedSearch, setDebouncedSearch] = useState(""); - const [verification, setVerification] = - useState(DEFAULT_VERIFICATION); - const [resourceType, setResourceType] = useState( - DEFAULT_RESOURCE_TYPE, - ); - const [minPrice, setMinPrice] = useState(""); - const [maxPrice, setMaxPrice] = useState(""); - const [sortBy, setSortBy] = useState("newest"); - const [loading, setLoading] = useState(true); - const [refreshing, setRefreshing] = useState(false); - const [error, setError] = useState(null); - const [registryFailed, setRegistryFailed] = useState(false); - const [toast, setToast] = useState(null); - const [lastUpdatedAt, setLastUpdatedAt] = useState(null); - - useEffect(() => { - async function restoreFilters() { - try { - const raw = await AsyncStorage.getItem(CATALOG_FILTERS_KEY); - if (raw) { - const saved: PersistedFilters = JSON.parse(raw); - setSearch(saved.search); - setDebouncedSearch(saved.search); - setVerification(saved.verification); - setResourceType(saved.resourceType); - setMinPrice(saved.minPrice); - setMaxPrice(saved.maxPrice); - } - } catch { - } finally { - filtersRestored.current = true; - } - } - restoreFilters(); - }, []); - - // Debounce the search text so the catalog only refetches after typing - // pauses, rather than on every keystroke. - useEffect(() => { - const handle = setTimeout(() => { - setDebouncedSearch(search); - }, SEARCH_DEBOUNCE_MS); - return () => clearTimeout(handle); - }, [search]); - - useEffect(() => { - async function persistFilters() { - if (!filtersRestored.current) return; - const data: PersistedFilters = { search, verification, resourceType, minPrice, maxPrice }; - try { - await AsyncStorage.setItem(CATALOG_FILTERS_KEY, JSON.stringify(data)); - } catch { - } - } - persistFilters(); - }, [search, verification, resourceType, minPrice, maxPrice]); - - const loadData = useCallback(async (isRefresh = false) => { - if (isRefresh) { - setRefreshing(true); - } else { - setLoading(true); - } - setError(null); - - const filters: CatalogFilters = {}; - if (debouncedSearch.trim()) filters.search = debouncedSearch.trim(); - if (verification !== DEFAULT_VERIFICATION) { - filters.verificationStatus = verification; - } - if (resourceType !== DEFAULT_RESOURCE_TYPE) { - filters.resourceType = resourceType; - } - - try { - let registryResult: { resourceCount: number } | null = null; - try { - registryResult = await fetchRegistryStatus(); - setRegistryFailed(false); - } catch { - setRegistryFailed(true); - registryResult = null; - } - const [catalog] = await Promise.all([ - fetchCatalog(Object.keys(filters).length > 0 ? filters : undefined), - ]); - setResources(catalog); - setRegistryCount(registryResult?.resourceCount ?? null); - setLastUpdatedAt(new Date()); - } catch (err) { - const message = - err instanceof Error - ? err.message - : "Something went wrong loading the catalog."; - setError(message); - } finally { - setLoading(false); - setRefreshing(false); - } - }, [debouncedSearch, verification, resourceType]); - - useEffect(() => { - void loadData(); - }, [loadData]); - - useEffect(() => { - if (!toast) return; - const timer = setTimeout(() => setToast(null), 2500); - return () => clearTimeout(timer); - }, [toast]); - - const hasActiveFilters = - search.trim() !== "" || - verification !== DEFAULT_VERIFICATION || - resourceType !== DEFAULT_RESOURCE_TYPE || - minPrice.trim() !== "" || - maxPrice.trim() !== ""; - - const clearFilters = useCallback(() => { - setSearch(""); - setDebouncedSearch(""); - setVerification(DEFAULT_VERIFICATION); - setResourceType(DEFAULT_RESOURCE_TYPE); - setMinPrice(""); - setMaxPrice(""); - setSortBy("newest"); - }, []); - - const filteredResources = useMemo(() => { - const min = Number.parseFloat(minPrice); - const max = Number.parseFloat(maxPrice); - const hasMin = !Number.isNaN(min); - const hasMax = !Number.isNaN(max); - - let result: Resource[]; - if (!hasMin && !hasMax) { - result = [...resources]; - } else { - result = resources.filter((resource) => { - const price = Number.parseFloat(resource.price); - if (Number.isNaN(price)) return false; - if (hasMin && price < min) return false; - if (hasMax && price > max) return false; - return true; - }); - } - - if (sortBy === "title") { - result.sort((a, b) => a.title.localeCompare(b.title)); - } else if (sortBy === "price") { - result.sort((a, b) => { - const pa = Number.parseFloat(a.price); - const pb = Number.parseFloat(b.price); - if (Number.isNaN(pa) && Number.isNaN(pb)) return 0; - if (Number.isNaN(pa)) return 1; - if (Number.isNaN(pb)) return -1; - return pa - pb; - }); - } - - return result; - }, [resources, minPrice, maxPrice, sortBy]); - - function renderEmpty() { - if (loading) return null; - - return ( - - - {resources.length > 0 ? "No matches" : "The catalog is empty"} - - - {resources.length > 0 - ? "Try adjusting or clearing your filters." - : "No resources have been published yet. Connect to a running MindVault server to browse the vault."} - - - ); - } - - return ( - - - item.id} - contentContainerStyle={styles.listContent} - refreshControl={ - void loadData(true)} - /> - } - ListHeaderComponent={ - - - - MindVault - - Payment-protected digital resources on Stellar - - {registryCount !== null ? ( - - {registryCount} resource{registryCount === 1 ? "" : "s"}{" "} - on-chain - - ) : registryFailed ? ( - - On-chain count unavailable - - ) : null} - {lastUpdatedAt ? ( - - Last updated {formatLastUpdated(lastUpdatedAt)} - - ) : null} - - navigation.navigate("Settings")} - accessibilityRole="button" - accessibilityLabel="Open settings" - > - - - - - - - - - Verification - - {VERIFICATION_OPTIONS.map((option) => { - const active = verification === option.value; - return ( - setVerification(option.value)} - style={[styles.chip, active && styles.chipActive]} - accessibilityRole="radio" - accessibilityState={{ checked: active }} - accessibilityLabel={option.label} - accessibilityHint={ - active - ? "Currently selected" - : "Tap to filter by this status" - } - > - - {option.label} - - - ); - })} - - - - - Type - - {RESOURCE_TYPE_OPTIONS.map((option) => { - const active = resourceType === option.value; - return ( - setResourceType(option.value)} - style={[styles.chip, active && styles.chipActive]} - accessibilityRole="radio" - accessibilityState={{ checked: active }} - accessibilityLabel={option.label} - accessibilityHint={ - active - ? "Currently selected" - : "Tap to filter by this type" - } - > - - {option.label} - - - ); - })} - - - - - Price (USDC) - - - - - - - - - - - - Sort by - - {SORT_OPTIONS.map((option) => { - const active = sortBy === option.value; - return ( - setSortBy(option.value)} - style={[styles.chip, active && styles.chipActive]} - accessibilityRole="radio" - accessibilityState={{ checked: active }} - accessibilityLabel={option.label} - > - - {option.label} - - - ); - })} - - - - {!loading && resources.length > 0 ? ( - - - Showing {filteredResources.length} of {resources.length}{" "} - resource - {resources.length === 1 ? "" : "s"} - - - Clear filters - - - ) : null} - - - API: {getApiBaseUrl()} - - {error ? ( - void loadData()} /> - ) : null} - - {loading ? ( - - {Array.from({ length: 4 }).map((_, i) => ( - - ))} - - ) : null} - - } - renderItem={({ item }) => ( - - navigation.navigate("ResourceDetail", { resourceId: item.id }) - } - /> - )} - ItemSeparatorComponent={() => } - ListEmptyComponent={renderEmpty} - /> - - navigation.navigate("Scanner")} - accessibilityRole="button" - accessibilityLabel="Scan QR code" - > - Scan QR - - - {toast ? ( - - {toast} - - ) : null} - - ); -} - +// @ts-nocheck +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + Pressable, + RefreshControl, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { StatusBar } from "expo-status-bar"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; + +import { + fetchCatalog, + fetchRegistryStatus, + getApiBaseUrl, +} from "../api/resources"; +import { ErrorBanner } from "../components/ErrorBanner"; +import { ResourceCard } from "../components/ResourceCard"; +import { SkeletonCard } from "../components/SkeletonCard"; +import type { RootStackParamList } from "../navigation"; +import type { CatalogFilters, Resource, VerificationStatus } from "../types"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; +import { + INVALID_PRICE_RANGE_MESSAGE, + filterByPriceRange, + parsePriceRange, +} from "../utils/priceRange"; + +const CATALOG_FILTERS_KEY = "@mindvault_catalog_filters"; + +interface PersistedFilters { + search: string; + verification: VerificationFilter; + resourceType: ResourceTypeFilter; + minPrice: string; + maxPrice: string; +} + +interface CatalogScreenProps { + navigation: NativeStackNavigationProp; +} + +type VerificationFilter = "all" | VerificationStatus; +type ResourceTypeFilter = "all" | "file" | "link"; + +const DEFAULT_VERIFICATION: VerificationFilter = "all"; +const DEFAULT_RESOURCE_TYPE: ResourceTypeFilter = "all"; + +const VERIFICATION_OPTIONS: { value: VerificationFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "verified", label: "Verified" }, + { value: "pending", label: "Pending" }, + { value: "rejected", label: "Rejected" }, +]; + +const RESOURCE_TYPE_OPTIONS: { value: ResourceTypeFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "file", label: "File" }, + { value: "link", label: "Link" }, +]; + +function formatLastUpdated(value: Date): string { + return value.toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + listContent: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.md, + }, + header: { + gap: spacing.md, + marginBottom: spacing.lg, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + gap: spacing.md, + }, + settingsButton: { + borderRadius: 10, + backgroundColor: colors.neutralBg, + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + settingsButtonText: { + fontSize: 20, + color: colors.textMuted, + }, + registry: { + marginTop: spacing.xs, + fontSize: 13, + fontWeight: "600", + color: colors.primary, + }, + lastUpdated: { + fontSize: 12, + color: colors.textSubtle, + }, + searchInput: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: colors.text, + }, + filters: { + gap: spacing.md, + }, + filterGroup: { + gap: spacing.sm, + }, + filterLabel: { + fontSize: 12, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + color: colors.textMuted, + }, + chipRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: spacing.sm, + }, + chip: { + borderRadius: 999, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + paddingHorizontal: 14, + paddingVertical: 8, + }, + chipActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryMuted, + }, + chipText: { + fontSize: 13, + fontWeight: "500", + color: colors.textMuted, + }, + chipTextActive: { + color: colors.primary, + fontWeight: "600", + }, + priceRow: { + flexDirection: "row", + gap: spacing.md, + }, + priceField: { + flex: 1, + gap: spacing.xs, + }, + priceInput: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15, + color: colors.text, + }, + priceInputInvalid: { + borderColor: colors.danger, + }, + priceError: { + fontSize: 12, + lineHeight: 16, + color: colors.danger, + }, + resultsRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: spacing.md, + }, + resultsCount: { + flex: 1, + fontSize: 13, + color: colors.textMuted, + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: colors.neutralBg, + }, + clearButtonDisabled: { + opacity: 0.5, + }, + clearButtonText: { + fontSize: 13, + fontWeight: "600", + color: colors.primary, + }, + apiHint: { + fontSize: 11, + color: colors.textSubtle, + }, + skeletons: { + gap: 12, + }, + separator: { + height: spacing.md, + }, + emptyState: { + alignItems: "center", + paddingVertical: spacing.xxl, + gap: spacing.sm, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + color: colors.text, + }, + emptyBody: { + textAlign: "center", + fontSize: 14, + color: colors.textMuted, + maxWidth: 320, + lineHeight: 20, + }, + fab: { + position: "absolute", + right: spacing.lg, + bottom: spacing.xl, + backgroundColor: colors.primary, + borderRadius: 28, + paddingHorizontal: 20, + paddingVertical: 14, + elevation: 4, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }, + fabText: { + color: "#ffffff", + fontWeight: "700", + fontSize: 15, + }, + toast: { + position: "absolute", + bottom: spacing.xl, + left: spacing.lg, + right: spacing.lg, + backgroundColor: colors.text, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + toastText: { + color: colors.background, + textAlign: "center", + fontSize: 14, + fontWeight: "500", + }, + }); +} + +export function CatalogScreen({ navigation }: CatalogScreenProps) { + const { colors, shared, typography, colorScheme } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const filtersRestored = useRef(false); + + const [resources, setResources] = useState([]); + const [registryCount, setRegistryCount] = useState(null); + const [search, setSearch] = useState(""); + const [verification, setVerification] = + useState(DEFAULT_VERIFICATION); + const [resourceType, setResourceType] = useState( + DEFAULT_RESOURCE_TYPE, + ); + const [minPrice, setMinPrice] = useState(""); + const [maxPrice, setMaxPrice] = useState(""); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [registryFailed, setRegistryFailed] = useState(false); + const [toast, setToast] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + useEffect(() => { + async function restoreFilters() { + try { + const raw = await AsyncStorage.getItem(CATALOG_FILTERS_KEY); + if (raw) { + const saved: PersistedFilters = JSON.parse(raw); + setSearch(saved.search); + setVerification(saved.verification); + setResourceType(saved.resourceType); + setMinPrice(saved.minPrice); + setMaxPrice(saved.maxPrice); + } + } catch { + } finally { + filtersRestored.current = true; + } + } + restoreFilters(); + }, []); + + useEffect(() => { + async function persistFilters() { + if (!filtersRestored.current) return; + const data: PersistedFilters = { search, verification, resourceType, minPrice, maxPrice }; + try { + await AsyncStorage.setItem(CATALOG_FILTERS_KEY, JSON.stringify(data)); + } catch { + } + } + persistFilters(); + }, [search, verification, resourceType, minPrice, maxPrice]); + + const loadData = useCallback(async (isRefresh = false) => { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const filters: CatalogFilters = {}; + if (search.trim()) filters.search = search.trim(); + if (verification !== DEFAULT_VERIFICATION) { + filters.verificationStatus = verification; + } + if (resourceType !== DEFAULT_RESOURCE_TYPE) { + filters.resourceType = resourceType; + } + + try { + let registryResult: { resourceCount: number } | null = null; + try { + registryResult = await fetchRegistryStatus(); + setRegistryFailed(false); + } catch { + setRegistryFailed(true); + registryResult = null; + } + const [catalog] = await Promise.all([ + fetchCatalog(Object.keys(filters).length > 0 ? filters : undefined), + ]); + setResources(catalog); + setRegistryCount(registryResult?.resourceCount ?? null); + setLastUpdatedAt(new Date()); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Something went wrong loading the catalog."; + setError(message); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [search, verification, resourceType]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 2500); + return () => clearTimeout(timer); + }, [toast]); + + const hasActiveFilters = + search.trim() !== "" || + verification !== DEFAULT_VERIFICATION || + resourceType !== DEFAULT_RESOURCE_TYPE || + minPrice.trim() !== "" || + maxPrice.trim() !== ""; + + const clearFilters = useCallback(() => { + setSearch(""); + setVerification(DEFAULT_VERIFICATION); + setResourceType(DEFAULT_RESOURCE_TYPE); + setMinPrice(""); + setMaxPrice(""); + }, []); + + const priceRange = useMemo( + () => parsePriceRange(minPrice, maxPrice), + [minPrice, maxPrice], + ); + + const filteredResources = useMemo( + () => filterByPriceRange(resources, priceRange), + [resources, priceRange], + ); + + function renderEmpty() { + if (loading) return null; + + return ( + + + {resources.length > 0 ? "No matches" : "The catalog is empty"} + + + {resources.length > 0 + ? "Try adjusting or clearing your filters." + : "No resources have been published yet. Connect to a running MindVault server to browse the vault."} + + + ); + } + + return ( + + + item.id} + contentContainerStyle={styles.listContent} + refreshControl={ + void loadData(true)} + /> + } + ListHeaderComponent={ + + + + MindVault + + Payment-protected digital resources on Stellar + + {registryCount !== null ? ( + + {registryCount} resource{registryCount === 1 ? "" : "s"}{" "} + on-chain + + ) : registryFailed ? ( + + On-chain count unavailable + + ) : null} + {lastUpdatedAt ? ( + + Last updated {formatLastUpdated(lastUpdatedAt)} + + ) : null} + + navigation.navigate("Settings")} + accessibilityRole="button" + accessibilityLabel="Open settings" + > + + + + + + + + + Verification + + {VERIFICATION_OPTIONS.map((option) => { + const active = verification === option.value; + return ( + setVerification(option.value)} + style={[styles.chip, active && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ checked: active }} + accessibilityLabel={option.label} + accessibilityHint={ + active + ? "Currently selected" + : "Tap to filter by this status" + } + > + + {option.label} + + + ); + })} + + + + + Type + + {RESOURCE_TYPE_OPTIONS.map((option) => { + const active = resourceType === option.value; + return ( + setResourceType(option.value)} + style={[styles.chip, active && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ checked: active }} + accessibilityLabel={option.label} + accessibilityHint={ + active + ? "Currently selected" + : "Tap to filter by this type" + } + > + + {option.label} + + + ); + })} + + + + + Price (USDC) + + + + + + + + + {priceRange.isInvalid ? ( + + {INVALID_PRICE_RANGE_MESSAGE} + + ) : null} + + + {!loading && resources.length > 0 ? ( + + + Showing {filteredResources.length} of {resources.length}{" "} + resource + {resources.length === 1 ? "" : "s"} + + + Clear filters + + + ) : null} + + + API: {getApiBaseUrl()} + + {error ? ( + void loadData()} /> + ) : null} + + {loading ? ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ) : null} + + } + renderItem={({ item }) => ( + + navigation.navigate("ResourceDetail", { resourceId: item.id }) + } + /> + )} + ItemSeparatorComponent={() => } + ListEmptyComponent={renderEmpty} + /> + + navigation.navigate("Scanner")} + accessibilityRole="button" + accessibilityLabel="Scan QR code" + > + Scan QR + + + {toast ? ( + + {toast} + + ) : null} + + ); +} +// @ts-nocheck +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + FlatList, + Pressable, + RefreshControl, + StyleSheet, + Text, + TextInput, + View, +} from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; +import { StatusBar } from "expo-status-bar"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import { Ionicons } from "@expo/vector-icons"; + +import { + fetchCatalog, + fetchRegistryStatus, + getApiBaseUrl, +} from "../api/resources"; +import { ErrorBanner } from "../components/ErrorBanner"; +import { ResourceCard } from "../components/ResourceCard"; +import { SkeletonCard } from "../components/SkeletonCard"; +import type { RootStackParamList } from "../navigation"; +import type { CatalogFilters, Resource, VerificationStatus } from "../types"; +import { spacing } from "../theme"; +import type { ThemeColors } from "../theme"; +import { useAppTheme } from "../theme/ThemeProvider"; + +const CATALOG_FILTERS_KEY = "@mindvault_catalog_filters"; +const SEARCH_DEBOUNCE_MS = 300; + +interface PersistedFilters { + search: string; + verification: VerificationFilter; + resourceType: ResourceTypeFilter; + minPrice: string; + maxPrice: string; +} + +interface CatalogScreenProps { + navigation: NativeStackNavigationProp; +} + +type VerificationFilter = "all" | VerificationStatus; +type ResourceTypeFilter = "all" | "file" | "link"; + +const DEFAULT_VERIFICATION: VerificationFilter = "all"; +const DEFAULT_RESOURCE_TYPE: ResourceTypeFilter = "all"; + +const VERIFICATION_OPTIONS: { value: VerificationFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "verified", label: "Verified" }, + { value: "pending", label: "Pending" }, + { value: "rejected", label: "Rejected" }, +]; + +const RESOURCE_TYPE_OPTIONS: { value: ResourceTypeFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "file", label: "File" }, + { value: "link", label: "Link" }, +]; + +type SortBy = "newest" | "title" | "price"; + +const SORT_OPTIONS: { value: SortBy; label: string }[] = [ + { value: "newest", label: "Newest" }, + { value: "title", label: "Title" }, + { value: "price", label: "Price" }, +]; + +function formatLastUpdated(value: Date): string { + return value.toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + }); +} + +function createStyles(colors: ThemeColors) { + return StyleSheet.create({ + listContent: { + padding: spacing.lg, + paddingBottom: spacing.xxl, + gap: spacing.md, + }, + header: { + gap: spacing.md, + marginBottom: spacing.lg, + }, + headerRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "flex-start", + gap: spacing.md, + }, + settingsButton: { + borderRadius: 10, + backgroundColor: colors.neutralBg, + paddingHorizontal: 12, + paddingVertical: 8, + minHeight: 44, + minWidth: 44, + justifyContent: "center", + alignItems: "center", + }, + registry: { + marginTop: spacing.xs, + fontSize: 13, + fontWeight: "600", + color: colors.primary, + }, + lastUpdated: { + fontSize: 12, + color: colors.textSubtle, + }, + searchInput: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 15, + color: colors.text, + }, + filters: { + gap: spacing.md, + }, + filterGroup: { + gap: spacing.sm, + }, + filterLabel: { + fontSize: 12, + fontWeight: "600", + textTransform: "uppercase", + letterSpacing: 0.5, + color: colors.textMuted, + }, + chipRow: { + flexDirection: "row", + flexWrap: "wrap", + gap: spacing.sm, + }, + chip: { + borderRadius: 999, + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + paddingHorizontal: 14, + paddingVertical: 8, + }, + chipActive: { + borderColor: colors.primary, + backgroundColor: colors.primaryMuted, + }, + chipText: { + fontSize: 13, + fontWeight: "500", + color: colors.textMuted, + }, + chipTextActive: { + color: colors.primary, + fontWeight: "600", + }, + priceRow: { + flexDirection: "row", + gap: spacing.md, + }, + priceField: { + flex: 1, + gap: spacing.xs, + }, + priceInput: { + borderWidth: 1, + borderColor: colors.border, + backgroundColor: colors.surface, + borderRadius: 12, + paddingHorizontal: 14, + paddingVertical: 10, + fontSize: 15, + color: colors.text, + }, + resultsRow: { + flexDirection: "row", + justifyContent: "space-between", + alignItems: "center", + gap: spacing.md, + }, + resultsCount: { + flex: 1, + fontSize: 13, + color: colors.textMuted, + }, + clearButton: { + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + backgroundColor: colors.neutralBg, + }, + clearButtonDisabled: { + opacity: 0.5, + }, + clearButtonText: { + fontSize: 13, + fontWeight: "600", + color: colors.primary, + }, + apiHint: { + fontSize: 11, + color: colors.textSubtle, + }, + skeletons: { + gap: 12, + }, + separator: { + height: spacing.md, + }, + emptyState: { + alignItems: "center", + paddingVertical: spacing.xxl, + gap: spacing.sm, + }, + emptyTitle: { + fontSize: 18, + fontWeight: "600", + color: colors.text, + }, + emptyBody: { + textAlign: "center", + fontSize: 14, + color: colors.textMuted, + maxWidth: 320, + lineHeight: 20, + }, + fab: { + position: "absolute", + right: spacing.lg, + bottom: spacing.xl, + backgroundColor: colors.primary, + borderRadius: 28, + paddingHorizontal: 20, + paddingVertical: 14, + elevation: 4, + shadowColor: "#000", + shadowOffset: { width: 0, height: 2 }, + shadowOpacity: 0.2, + shadowRadius: 4, + }, + fabText: { + color: "#ffffff", + fontWeight: "700", + fontSize: 15, + }, + toast: { + position: "absolute", + bottom: spacing.xl, + left: spacing.lg, + right: spacing.lg, + backgroundColor: colors.text, + borderRadius: 12, + paddingVertical: 12, + paddingHorizontal: 16, + }, + toastText: { + color: colors.background, + textAlign: "center", + fontSize: 14, + fontWeight: "500", + }, + }); +} + +export function CatalogScreen({ navigation }: CatalogScreenProps) { + const { colors, shared, typography, colorScheme } = useAppTheme(); + const styles = useMemo(() => createStyles(colors), [colors]); + const filtersRestored = useRef(false); + + const [resources, setResources] = useState([]); + const [registryCount, setRegistryCount] = useState(null); + const [search, setSearch] = useState(""); + const [debouncedSearch, setDebouncedSearch] = useState(""); + const [verification, setVerification] = + useState(DEFAULT_VERIFICATION); + const [resourceType, setResourceType] = useState( + DEFAULT_RESOURCE_TYPE, + ); + const [minPrice, setMinPrice] = useState(""); + const [maxPrice, setMaxPrice] = useState(""); + const [sortBy, setSortBy] = useState("newest"); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [registryFailed, setRegistryFailed] = useState(false); + const [toast, setToast] = useState(null); + const [lastUpdatedAt, setLastUpdatedAt] = useState(null); + + useEffect(() => { + async function restoreFilters() { + try { + const raw = await AsyncStorage.getItem(CATALOG_FILTERS_KEY); + if (raw) { + const saved: PersistedFilters = JSON.parse(raw); + setSearch(saved.search); + setDebouncedSearch(saved.search); + setVerification(saved.verification); + setResourceType(saved.resourceType); + setMinPrice(saved.minPrice); + setMaxPrice(saved.maxPrice); + } + } catch { + } finally { + filtersRestored.current = true; + } + } + restoreFilters(); + }, []); + + // Debounce the search text so the catalog only refetches after typing + // pauses, rather than on every keystroke. + useEffect(() => { + const handle = setTimeout(() => { + setDebouncedSearch(search); + }, SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [search]); + + useEffect(() => { + async function persistFilters() { + if (!filtersRestored.current) return; + const data: PersistedFilters = { search, verification, resourceType, minPrice, maxPrice }; + try { + await AsyncStorage.setItem(CATALOG_FILTERS_KEY, JSON.stringify(data)); + } catch { + } + } + persistFilters(); + }, [search, verification, resourceType, minPrice, maxPrice]); + + const loadData = useCallback(async (isRefresh = false) => { + if (isRefresh) { + setRefreshing(true); + } else { + setLoading(true); + } + setError(null); + + const filters: CatalogFilters = {}; + if (debouncedSearch.trim()) filters.search = debouncedSearch.trim(); + if (verification !== DEFAULT_VERIFICATION) { + filters.verificationStatus = verification; + } + if (resourceType !== DEFAULT_RESOURCE_TYPE) { + filters.resourceType = resourceType; + } + + try { + let registryResult: { resourceCount: number } | null = null; + try { + registryResult = await fetchRegistryStatus(); + setRegistryFailed(false); + } catch { + setRegistryFailed(true); + registryResult = null; + } + const [catalog] = await Promise.all([ + fetchCatalog(Object.keys(filters).length > 0 ? filters : undefined), + ]); + setResources(catalog); + setRegistryCount(registryResult?.resourceCount ?? null); + setLastUpdatedAt(new Date()); + } catch (err) { + const message = + err instanceof Error + ? err.message + : "Something went wrong loading the catalog."; + setError(message); + } finally { + setLoading(false); + setRefreshing(false); + } + }, [debouncedSearch, verification, resourceType]); + + useEffect(() => { + void loadData(); + }, [loadData]); + + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 2500); + return () => clearTimeout(timer); + }, [toast]); + + const hasActiveFilters = + search.trim() !== "" || + verification !== DEFAULT_VERIFICATION || + resourceType !== DEFAULT_RESOURCE_TYPE || + minPrice.trim() !== "" || + maxPrice.trim() !== ""; + + const clearFilters = useCallback(() => { + setSearch(""); + setDebouncedSearch(""); + setVerification(DEFAULT_VERIFICATION); + setResourceType(DEFAULT_RESOURCE_TYPE); + setMinPrice(""); + setMaxPrice(""); + setSortBy("newest"); + }, []); + + const filteredResources = useMemo(() => { + const min = Number.parseFloat(minPrice); + const max = Number.parseFloat(maxPrice); + const hasMin = !Number.isNaN(min); + const hasMax = !Number.isNaN(max); + + let result: Resource[]; + if (!hasMin && !hasMax) { + result = [...resources]; + } else { + result = resources.filter((resource) => { + const price = Number.parseFloat(resource.price); + if (Number.isNaN(price)) return false; + if (hasMin && price < min) return false; + if (hasMax && price > max) return false; + return true; + }); + } + + if (sortBy === "title") { + result.sort((a, b) => a.title.localeCompare(b.title)); + } else if (sortBy === "price") { + result.sort((a, b) => { + const pa = Number.parseFloat(a.price); + const pb = Number.parseFloat(b.price); + if (Number.isNaN(pa) && Number.isNaN(pb)) return 0; + if (Number.isNaN(pa)) return 1; + if (Number.isNaN(pb)) return -1; + return pa - pb; + }); + } + + return result; + }, [resources, minPrice, maxPrice, sortBy]); + + function renderEmpty() { + if (loading) return null; + + return ( + + + {resources.length > 0 ? "No matches" : "The catalog is empty"} + + + {resources.length > 0 + ? "Try adjusting or clearing your filters." + : "No resources have been published yet. Connect to a running MindVault server to browse the vault."} + + + ); + } + + return ( + + + item.id} + contentContainerStyle={styles.listContent} + refreshControl={ + void loadData(true)} + /> + } + ListHeaderComponent={ + + + + MindVault + + Payment-protected digital resources on Stellar + + {registryCount !== null ? ( + + {registryCount} resource{registryCount === 1 ? "" : "s"}{" "} + on-chain + + ) : registryFailed ? ( + + On-chain count unavailable + + ) : null} + {lastUpdatedAt ? ( + + Last updated {formatLastUpdated(lastUpdatedAt)} + + ) : null} + + navigation.navigate("Settings")} + accessibilityRole="button" + accessibilityLabel="Open settings" + > + + + + + + + + + Verification + + {VERIFICATION_OPTIONS.map((option) => { + const active = verification === option.value; + return ( + setVerification(option.value)} + style={[styles.chip, active && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ checked: active }} + accessibilityLabel={option.label} + accessibilityHint={ + active + ? "Currently selected" + : "Tap to filter by this status" + } + > + + {option.label} + + + ); + })} + + + + + Type + + {RESOURCE_TYPE_OPTIONS.map((option) => { + const active = resourceType === option.value; + return ( + setResourceType(option.value)} + style={[styles.chip, active && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ checked: active }} + accessibilityLabel={option.label} + accessibilityHint={ + active + ? "Currently selected" + : "Tap to filter by this type" + } + > + + {option.label} + + + ); + })} + + + + + Price (USDC) + + + + + + + + + + + + Sort by + + {SORT_OPTIONS.map((option) => { + const active = sortBy === option.value; + return ( + setSortBy(option.value)} + style={[styles.chip, active && styles.chipActive]} + accessibilityRole="radio" + accessibilityState={{ checked: active }} + accessibilityLabel={option.label} + > + + {option.label} + + + ); + })} + + + + {!loading && resources.length > 0 ? ( + + + Showing {filteredResources.length} of {resources.length}{" "} + resource + {resources.length === 1 ? "" : "s"} + + + Clear filters + + + ) : null} + + + API: {getApiBaseUrl()} + + {error ? ( + void loadData()} /> + ) : null} + + {loading ? ( + + {Array.from({ length: 4 }).map((_, i) => ( + + ))} + + ) : null} + + } + renderItem={({ item }) => ( + + navigation.navigate("ResourceDetail", { resourceId: item.id }) + } + /> + )} + ItemSeparatorComponent={() => } + ListEmptyComponent={renderEmpty} + /> + + navigation.navigate("Scanner")} + accessibilityRole="button" + accessibilityLabel="Scan QR code" + > + Scan QR + + + {toast ? ( + + {toast} + + ) : null} + + ); +} + diff --git a/src/utils/priceRange.test.ts b/src/utils/priceRange.test.ts new file mode 100644 index 0000000..9035282 --- /dev/null +++ b/src/utils/priceRange.test.ts @@ -0,0 +1,82 @@ +import { filterByPriceRange, parsePriceRange } from "./priceRange"; + +const items = [ + { id: "a", price: "1.00" }, + { id: "b", price: "5.00" }, + { id: "c", price: "10.00" }, +]; + +describe("parsePriceRange", () => { + it("treats blank and non-numeric fields as unset bounds", () => { + expect(parsePriceRange("", "")).toEqual({ + min: null, + max: null, + isInvalid: false, + isActive: false, + }); + expect(parsePriceRange("abc", " ")).toEqual({ + min: null, + max: null, + isInvalid: false, + isActive: false, + }); + }); + + it("is active when a single bound is set", () => { + expect(parsePriceRange("2", "")).toMatchObject({ min: 2, max: null, isActive: true }); + expect(parsePriceRange("", "2")).toMatchObject({ min: null, max: 2, isActive: true }); + }); + + it("accepts a range where the minimum equals the maximum", () => { + expect(parsePriceRange("5", "5")).toMatchObject({ isInvalid: false, isActive: true }); + }); + + it("flags an inverted range as invalid and inactive", () => { + expect(parsePriceRange("10", "5")).toMatchObject({ + min: 10, + max: 5, + isInvalid: true, + isActive: false, + }); + }); + + it("does not flag a partial range as invalid", () => { + expect(parsePriceRange("10", "")).toMatchObject({ isInvalid: false }); + expect(parsePriceRange("", "5")).toMatchObject({ isInvalid: false }); + }); +}); + +describe("filterByPriceRange", () => { + it("returns every item when no bound is set", () => { + expect(filterByPriceRange(items, parsePriceRange("", ""))).toEqual(items); + }); + + it("applies the bounds inclusively", () => { + expect(filterByPriceRange(items, parsePriceRange("1", "5")).map((i) => i.id)).toEqual([ + "a", + "b", + ]); + expect(filterByPriceRange(items, parsePriceRange("5", "")).map((i) => i.id)).toEqual([ + "b", + "c", + ]); + expect(filterByPriceRange(items, parsePriceRange("", "5")).map((i) => i.id)).toEqual([ + "a", + "b", + ]); + }); + + it("leaves the list untouched when the range is inverted", () => { + expect(filterByPriceRange(items, parsePriceRange("10", "5"))).toEqual(items); + }); + + it("drops items without a numeric price while the range is applied", () => { + const withUnpriced = [...items, { id: "d", price: "free" }]; + expect(filterByPriceRange(withUnpriced, parsePriceRange("1", "10")).map((i) => i.id)).toEqual([ + "a", + "b", + "c", + ]); + expect(filterByPriceRange(withUnpriced, parsePriceRange("", ""))).toEqual(withUnpriced); + }); +}); diff --git a/src/utils/priceRange.ts b/src/utils/priceRange.ts new file mode 100644 index 0000000..3ea6ea8 --- /dev/null +++ b/src/utils/priceRange.ts @@ -0,0 +1,63 @@ +/** + * Parsing and filtering helpers for the catalog's min/max price range. + * + * The range is entered as two free-text fields, so either side can be blank + * or not a number, and the minimum can end up above the maximum. An inverted + * range has no possible match, so instead of silently emptying the catalog it + * is reported as invalid: callers surface the message and leave the price + * filter switched off until the range is corrected. + */ + +export interface PriceRange { + /** Parsed minimum, or `null` when the field is blank or not a number. */ + min: number | null; + /** Parsed maximum, or `null` when the field is blank or not a number. */ + max: number | null; + /** True when both bounds are set and the minimum is above the maximum. */ + isInvalid: boolean; + /** True when the range can be applied, i.e. it has a bound and is valid. */ + isActive: boolean; +} + +/** Message shown next to the price inputs while the range is inverted. */ +export const INVALID_PRICE_RANGE_MESSAGE = + "Minimum price is higher than maximum price. The price filter is off until you fix the range."; + +function parseBound(value: string): number | null { + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? null : parsed; +} + +export function parsePriceRange(minPrice: string, maxPrice: string): PriceRange { + const min = parseBound(minPrice); + const max = parseBound(maxPrice); + const isInvalid = min !== null && max !== null && min > max; + + return { + min, + max, + isInvalid, + isActive: !isInvalid && (min !== null || max !== null), + }; +} + +/** + * Filters items by the given range. An inactive range (no bounds, or an + * inverted one) leaves the list untouched, so the other filters keep working + * on their own. Items without a numeric price are dropped whenever the range + * is applied, since they cannot be compared against a bound. + */ +export function filterByPriceRange( + items: T[], + range: PriceRange +): T[] { + if (!range.isActive) return items; + + return items.filter((item) => { + const price = Number.parseFloat(item.price); + if (Number.isNaN(price)) return false; + if (range.min !== null && price < range.min) return false; + if (range.max !== null && price > range.max) return false; + return true; + }); +}