From 2d5f56665ff4941814129b9e8d96b28c04041859 Mon Sep 17 00:00:00 2001 From: noevidence1017 Date: Tue, 28 Jul 2026 17:15:23 +0100 Subject: [PATCH] Handle inverted catalog price ranges with inline feedback Entering a minimum above the maximum produced an unexplained empty catalog: the filter kept applying, so every resource was dropped and the only clue was a "No matches" state that did not say which filter was at fault. The min/max parsing and filtering now live in a small pure helper. An inverted range is reported as invalid and treated as inactive, so the list keeps showing the results of the remaining filters instead of emptying, and the catalog screen marks both price inputs and renders an inline message explaining that the price filter is off until the range is corrected. Valid ranges, single-sided ranges and blank fields behave exactly as before. --- src/screens/CatalogScreen.tsx | 1343 +++++++++++++++++---------------- src/utils/priceRange.test.ts | 82 ++ src/utils/priceRange.ts | 63 ++ 3 files changed, 830 insertions(+), 658 deletions(-) create mode 100644 src/utils/priceRange.test.ts create mode 100644 src/utils/priceRange.ts diff --git a/src/screens/CatalogScreen.tsx b/src/screens/CatalogScreen.tsx index 840c91b..526ab15 100644 --- a/src/screens/CatalogScreen.tsx +++ b/src/screens/CatalogScreen.tsx @@ -1,658 +1,685 @@ -// @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"; - -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, - }, - 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 filteredResources = useMemo(() => { - const min = Number.parseFloat(minPrice); - const max = Number.parseFloat(maxPrice); - const hasMin = !Number.isNaN(min); - const hasMax = !Number.isNaN(max); - - if (!hasMin && !hasMax) return resources; - - return 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; - }); - }, [resources, minPrice, maxPrice]); - - 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) - - - - - - - - - - - {!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} + + ); +} 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; + }); +}