import type { RoadmapItem, RoadmapStatus } from "convex-feedback"; import type { ReactElement, ReactNode } from "react"; import { ActivityIndicator, FlatList, Pressable, ScrollView, StyleSheet, Text, View, type StyleProp, type ViewProps, type ViewStyle, } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useFeedbackUi } from "../../../shared/context/FeedbackProvider.js"; export interface RoadmapBoardStage { value: RoadmapStatus; label: string; } export interface RoadmapBoardItemRenderArgs { item: RoadmapItem; stage: RoadmapBoardStage; stageIndex: number; onOpen: () => void; } export interface RoadmapBoardProps { /** Items are grouped into columns using their current status. */ items: readonly RoadmapItem[]; /** Ordered columns rendered from left to right. */ stages: readonly RoadmapBoardStage[]; /** Shows the same full-board loading state as the admin roadmap. */ loading?: boolean; /** Copy shown when a column does not contain any items. */ emptyLabel: string; /** Opens a roadmap item from any rendered card. */ onItemOpen: (item: RoadmapItem) => void; /** Supplies the card UI so admin and user-facing cards can differ. */ renderItem: (args: RoadmapBoardItemRenderArgs) => ReactElement | null; /** * Additional top space reserved for content behind a transparent native * stack header. Defaults to `0`; pass the full header height when the host * renders the board under a transparent header. */ topInset?: number; /** * Total bottom space reserved for a host navigation bar or overlay. Defaults * to the bottom safe-area inset. An explicit value replaces that default, * including `0`. */ bottomInset?: number; style?: StyleProp; contentContainerStyle?: StyleProp; /** Additional styles for each stage column. */ stageStyle?: StyleProp; } export interface RoadmapBoardCardProps extends Pick< ViewProps, "accessibilityLabel" > { onPress: () => void; children: ReactNode; style?: StyleProp; } /** Shared admin-style pressable card shell with configurable contents. */ export function RoadmapBoardCard({ onPress, accessibilityLabel, children, style, }: RoadmapBoardCardProps) { const { theme } = useFeedbackUi(); const cardColors = { border: theme.colors.border, surface: theme.colors.surface, }; return ( [ styles.card, { borderColor: cardColors.border, backgroundColor: cardColors.surface, }, style, pressed && styles.pressed, ]} > {children} ); } /** * Shared stage-board layout used by the public roadmap and admin app. * * The outer list scrolls horizontally while each stage owns its vertical * `FlatList`, matching the admin board's bounded nested-scroll structure. * `bottomInset` is treated as total host-occupied space, so a caller that * renders an overlay must include both the overlay and any safe-area space. */ export function RoadmapBoard({ items, stages, loading = false, emptyLabel, onItemOpen, renderItem, topInset = 0, bottomInset, style, contentContainerStyle, stageStyle, }: RoadmapBoardProps) { const insets = useSafeAreaInsets(); const resolvedBottomInset = bottomInset ?? insets.bottom; const { theme } = useFeedbackUi(); const resolvedColors = theme.colors; if (loading) { return ( ); } return ( {stages.map((stage, stageIndex) => { const stageItems = items .filter((item) => item.status === stage.value) .sort((a, b) => a.position - b.position); return ( {stage.label} {stageItems.length} item.id} contentContainerStyle={[ styles.cards, { paddingBottom: 16 + resolvedBottomInset }, ]} ListEmptyComponent={ {emptyLabel} } renderItem={({ item }) => renderItem({ item, stage, stageIndex, onOpen: () => onItemOpen(item), }) } /> ); })} ); } const styles = StyleSheet.create({ boardScroll: { flex: 1 }, loader: { flex: 1 }, board: { gap: 12 }, column: { width: 310, borderWidth: 1, borderRadius: 16, overflow: "hidden", }, columnHeader: { flexDirection: "row", alignItems: "center", justifyContent: "space-between", borderBottomWidth: 1, padding: 14, }, columnTitle: { fontSize: 14, fontWeight: "700" }, count: { fontSize: 12 }, cards: { gap: 10, padding: 10 }, empty: { padding: 18, textAlign: "center" }, card: { gap: 8, borderWidth: 1, borderRadius: 12, padding: 13, }, pressed: { opacity: 0.7 }, });