import type { RoadmapItem, RoadmapStatus } from "convex-feedback";
import { useState, type PropsWithChildren } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
FeedbackBodyProvider,
useFeedbackBody,
} from "../../../shared/context/FeedbackBodyProvider.js";
import {
FeedbackProvider,
useFeedbackUi,
} from "../../../shared/context/FeedbackProvider.js";
import { kinds } from "../../../shared/helpers.js";
import type { RoadmapScreenProps } from "../../../shared/types/index.js";
import { collectNativeMetadata } from "../metadata.js";
import { Button } from "./Button.js";
import { EntryCard } from "./EntryCard.js";
import { EntryDetail } from "./EntryDetail.js";
import { RoadmapBoard, RoadmapBoardCard } from "./RoadmapBoard.js";
import { FeedbackBoard } from "./primitives.js";
const stages: readonly { value: RoadmapStatus }[] = [
{ value: "planned" },
{ value: "in_progress" },
{ value: "shipped" },
];
/**
* Keeps the roadmap and its attached entry details on the same UI context.
* Routed Expo layouts reuse this provider so every route sees the same hooks,
* theme, messages, and comment configuration.
*/
export function RoadmapProvider({
hooks,
messages,
theme,
unstyled,
commentSort = "top",
maxCommentDepth = 5,
transformComments,
renderActor,
onUnauthenticated,
children,
}: PropsWithChildren<
Pick<
RoadmapScreenProps,
| "hooks"
| "messages"
| "theme"
| "unstyled"
| "commentSort"
| "maxCommentDepth"
| "transformComments"
| "renderActor"
| "onUnauthenticated"
>
>) {
return (
{children}
);
}
/** Self-contained public roadmap screen for native and non-routed Expo apps. */
export function RoadmapScreen({
hooks,
messages,
theme,
unstyled,
onEntryOpen,
onUnauthenticated,
commentSort,
maxCommentDepth,
transformComments,
renderActor,
pageSize = hooks.pageSizes.roadmap,
entryPageSize = hooks.pageSizes.entries,
...colors
}: RoadmapScreenProps) {
return (
);
}
export interface RoadmapScreenContentProps extends Pick<
RoadmapScreenProps,
| "onEntryOpen"
| "onUnauthenticated"
| "primaryColor"
| "primaryForeground"
| "backgroundColor"
| "surfaceColor"
| "inputColor"
| "textColor"
| "mutedColor"
| "borderColor"
| "dangerColor"
> {
pageSize: number;
entryPageSize: number;
/** Controlled search value used by Expo Router's native Stack.SearchBar. */
query?: string;
/** Controlled search setter used by Expo Router's native Stack.SearchBar. */
onQueryChange?: (query: string) => void;
/** Renders the inline title/search header for non-Stack usage. @default true */
showBoardHeader?: boolean;
/**
* Additional top space reserved for content behind a transparent native
* stack header. Defaults to `0`.
*/
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;
}
/**
* Shared screen content used by direct native and Expo Stack integrations.
* Consumers using an overlaying host toolbar should pass its full occupied
* height through `bottomInset`; NativeTabs consumers normally omit it because
* the tab bar supplies a bounded content region.
*/
export function RoadmapScreenContent({
pageSize,
entryPageSize,
onEntryOpen,
query,
onQueryChange,
showBoardHeader = true,
topInset,
bottomInset,
...colors
}: RoadmapScreenContentProps) {
const [selected, setSelected] = useState(null);
const { hooks } = useFeedbackBody();
const selectedQuery = hooks.useRoadmapItem(selected?.id);
const authoritativeSelected =
selectedQuery === undefined ? selected : selectedQuery;
return (
{authoritativeSelected === null ? (
) : (
setSelected(null)}
/>
)}
);
}
/** Board content used by both the self-contained and routed Expo screens. */
export function RoadmapBoardContent({
pageSize,
onItemOpen,
query: controlledQuery,
onQueryChange,
showHeader = true,
topInset = 0,
bottomInset,
}: {
pageSize: number;
onItemOpen: (item: RoadmapItem) => void;
query?: string;
onQueryChange?: (query: string) => void;
showHeader?: boolean;
/** Additional top space reserved below a transparent native stack header. */
topInset?: number;
/** Total bottom space reserved for a host navigation bar or overlay. */
bottomInset?: number;
}) {
const { hooks } = useFeedbackBody();
const { messages, theme } = useFeedbackUi();
const insets = useSafeAreaInsets();
const resolvedBottomInset = bottomInset ?? insets.bottom;
const [internalQuery, setInternalQuery] = useState("");
const query = controlledQuery ?? internalQuery;
const setQuery = onQueryChange ?? setInternalQuery;
const roadmap = hooks.useRoadmap();
const search = hooks.useSearchRoadmap(query);
const searching = query.trim().length > 0;
const items = searching ? (search ?? []) : roadmap.results;
const loading = searching
? search === undefined
: roadmap.status === "LoadingFirstPage";
return (
{showHeader && (
{messages.roadmap.title}
{messages.roadmap.subtitle}
)}
{!loading && items.length === 0 ? (
{searching
? messages.roadmap.noSearchResults
: messages.roadmap.noItems}
) : (
({
...stage,
label: messages.roadmap.statuses[stage.value],
}))}
emptyLabel={messages.roadmap.emptyStage}
loading={loading}
topInset={topInset}
bottomInset={resolvedBottomInset}
onItemOpen={onItemOpen}
renderItem={({ item, onOpen }) => (
)}
/>
)}
{!searching &&
(roadmap.status === "CanLoadMore" ||
roadmap.status === "LoadingMore") && (
)}
);
}
/** Detail content used by routed roadmap item pages and in-screen navigation. */
export function RoadmapItemContent({
item,
entryPageSize,
onEntryOpen,
}: {
item: RoadmapItem;
entryPageSize: number;
onEntryOpen?: (entryId: string) => void;
}) {
const { hooks } = useFeedbackBody();
const { messages, theme } = useFeedbackUi();
const feedback = hooks.useRoadmapFeedback(item.id);
const [selectedEntryId, setSelectedEntryId] = useState(null);
if (selectedEntryId !== null) {
return (
);
}
const openEntry = (entryId: string) => {
if (onEntryOpen === undefined) {
setSelectedEntryId(entryId);
} else {
onEntryOpen(entryId);
}
};
return (
{item.title}
{item.description ? (
{item.description}
) : null}
{messages.roadmap.attachedFeedback}
{feedback.status === "LoadingFirstPage" ? (
) : feedback.results.length === 0 ? (
{messages.roadmap.noAttachedFeedback}
) : (
{feedback.results.map((entry) => (
openEntry(entry.id)}
/>
))}
)}
{(feedback.status === "CanLoadMore" ||
feedback.status === "LoadingMore") && (
);
}
function RoadmapDetailPage({
item,
entryPageSize,
onEntryOpen,
onBack,
}: {
item: RoadmapItem;
entryPageSize: number;
onEntryOpen?: (entryId: string) => void;
onBack: () => void;
}) {
const { theme, messages } = useFeedbackUi();
const insets = useSafeAreaInsets();
return (
);
}
function RoadmapCard({
item,
onOpen,
}: {
item: RoadmapItem;
onOpen: () => void;
}) {
const { theme } = useFeedbackUi();
return (
{item.title}
{item.description && (
{item.description}
)}
);
}
function RoadmapStatusBadge({ status }: { status: RoadmapStatus }) {
const { messages, theme } = useFeedbackUi();
return (
{messages.roadmap.statuses[status]}
);
}
const styles = StyleSheet.create({
boardScreen: { flex: 1, minHeight: 0 },
boardHeader: {
gap: 10,
paddingHorizontal: 18,
paddingBottom: 16,
},
subtitle: { fontSize: 14, lineHeight: 20 },
stateText: { textAlign: "center" },
cardMeta: {
flexDirection: "row",
alignItems: "center",
gap: 8,
},
cardTitle: { fontSize: 16, fontWeight: "700", lineHeight: 21 },
cardDescription: { fontSize: 13, lineHeight: 19 },
cardLink: { fontSize: 13, fontWeight: "700" },
statusBadge: {
alignSelf: "flex-start",
flexDirection: "row",
alignItems: "center",
gap: 5,
borderWidth: StyleSheet.hairlineWidth,
borderRadius: 999,
paddingHorizontal: 7,
paddingVertical: 4,
},
statusDot: { width: 6, height: 6, borderRadius: 3 },
statusText: { fontSize: 11, fontWeight: "700" },
detailScreen: { flex: 1, minHeight: 0 },
detailToolbar: { paddingHorizontal: 18, paddingBottom: 10 },
detailContent: { gap: 18 },
detailIntro: { gap: 10 },
detailTitle: { fontSize: 26, fontWeight: "800", lineHeight: 31 },
detailDescription: { fontSize: 15, lineHeight: 23 },
sectionTitle: { fontSize: 18, fontWeight: "800" },
loadingInline: { paddingVertical: 22 },
emptyText: { lineHeight: 20 },
entryList: { gap: 10 },
entryDetail: { gap: 16 },
});