Search-first support — filterable FAQ accordion with live result count, plus chat/email contact rows and a docs link
import * as React from "react";
import { useMemo, useState } from "react";
import { View, ScrollView } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, EmptyState, Glass, Icon, List, SearchInput, Text } from "@lumioui/ui";
import { BLOCK_STAGGER, LumioBot, Mount, RowIcon, SEPARATOR_INSET } from "../shared";
import { useHaptic } from "@lumioui/core";
export interface HelpArticle {
id: string;
question: string;
answer: string;
}
export interface HelpCenterScreenProps {
articles?: HelpArticle[];
onSearch?: (query: string) => void;
onContactChat?: () => void;
onContactEmail?: () => void;
onOpenDocs?: () => void;
onBack?: () => void;
}
const TOPICS = [
{ icon: "package", label: "Orders & returns", tone: "orange" },
{ icon: "credit-card", label: "Billing", tone: "blue" },
{ icon: "settings", label: "Account", tone: "purple" },
{ icon: "shield", label: "Privacy & safety", tone: "green" },
] as const;
const DEFAULT_ARTICLES: HelpArticle[] = [
{
id: "track-order",
question: "Where is my order?",
answer:
"Open Orders and tap Track on the active order — you'll see every checkpoint from the fulfillment center to your door, plus a live ETA once the courier is on the way.",
},
{
id: "returns",
question: "How do I return an item?",
answer:
"Orders → select the order → Start a return. You have 30 days from delivery. The prepaid label lands in your email within a minute, and the refund posts 3–5 days after the carrier scans it.",
},
{
id: "sign-in",
question: "How do I change my email or password?",
answer:
"Settings → Sign-in & security. Email changes need a quick verification code; password changes sign out your other devices automatically.",
},
{
id: "pro",
question: "What's included in Lumio Pro?",
answer:
"Every Pro component and screen template, priority support, and the weekly release train. Manage or cancel anytime under Settings → Subscription.",
},
{
id: "notifications",
question: "Why am I not getting notifications?",
answer:
"Check Settings → Push notifications first, then confirm system-level permission in your phone's settings. Order updates always land in-app under Notifications too.",
},
{
id: "delete-account",
question: "How do I delete my account?",
answer:
"Settings → Sign-in & security → Delete account. Your data is removed within 30 days. Active orders still ship, but the change can't be undone.",
},
];
/** Help center — search-first FAQ accordion with contact + docs rows. */
export function HelpCenterScreen({
articles = DEFAULT_ARTICLES,
onSearch,
onContactChat,
onContactEmail,
onOpenDocs,
onBack,
}: HelpCenterScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [query, setQuery] = useState("");
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return articles;
return articles.filter(
(a) =>
a.question.toLowerCase().includes(q) || a.answer.toLowerCase().includes(q)
);
}, [articles, query]);
const updateQuery = (text: string) => {
setQuery(text);
onSearch?.(text);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back pill + hug-label title pill. Search is the
page's own field below, so the header stays to the two pills. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="h-9 w-9"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Help center
</Text>
</Glass>
</View>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* Search — filters the FAQ below as you type. */}
<Mount className="px-6 pb-4 pt-2">
<SearchInput
placeholder="Search help topics"
value={query}
onChangeText={updateQuery}
onClear={() => updateQuery("")}
accessibilityLabel="Search help topics"
glass
/>
</Mount>
{/* Status line — help centers lead with "is it us or you?" */}
{!query.trim() ? (
<View className="mx-6 mb-4 flex-row items-center gap-2 rounded-xl bg-white px-3.5 py-2.5 dark:bg-[#1A1A20]">
<View className="h-2 w-2 rounded-full bg-success" />
<Text variant="small" className="flex-1 text-xs">
All systems operational
</Text>
<Text variant="muted" className="text-xs">
Status
</Text>
</View>
) : null}
{/* Topic tiles — the Intercom browse idiom: pick a lane before
reading the FAQ. */}
{!query.trim() ? (
<Mount delay={60}>
<View className="flex-row flex-wrap gap-3 px-6 pb-6">
{TOPICS.map((t) => (
<View key={t.label} className="w-[48%] flex-row items-center gap-3 rounded-2xl bg-white p-3.5 dark:bg-[#1A1A20]">
<RowIcon name={t.icon} tone={t.tone} />
<Text className="text-sm font-semibold">{t.label}</Text>
</View>
))}
</View>
</Mount>
) : null}
{results.length === 0 ? (
<EmptyState
variant="search"
title="No articles found"
description={`Nothing matches "${query.trim()}" — try a different term or reach out below.`}
className="px-6 py-10"
/>
) : (
<Mount delay={BLOCK_STAGGER}>
<View className="px-6">
<Text
variant="muted"
accessibilityRole="header"
className="pb-2 text-xs font-semibold uppercase tracking-wider"
>
{query.trim() ? `${results.length} result${results.length === 1 ? "" : "s"}` : "Frequently asked"}
</Text>
{/* AccordionTrigger fires its own light haptic — don't double it. */}
<Accordion
type="single"
collapsible
className="overflow-hidden rounded-3xl bg-white dark:bg-[#1A1A20]"
>
{results.map((article, i) => (
<AccordionItem
key={article.id}
value={article.id}
className={i > 0 ? "border-t border-border" : undefined}
>
<AccordionTrigger className="px-4">
{article.question}
</AccordionTrigger>
<AccordionContent className="px-4">
{article.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</View>
</Mount>
)}
{/* Contact + docs — the escape hatches when the FAQ didn't land. */}
<Mount delay={BLOCK_STAGGER * 2}>
<List inset glass className="pt-2">
<List.Section title="Still need help?">
<List.Item
title="Chat with support"
subtitle="Typically replies in ~5 min"
leading={<LumioBot size={30} />}
showChevron
haptic="light"
onPress={onContactChat}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Email us"
subtitle="support@lumio.dev"
leading={<RowIcon name="mail" tone="teal" />}
showChevron
haptic="light"
onPress={onContactEmail}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Documentation"
subtitle="docs.lumio.dev"
leading={<RowIcon name="book-open" tone="purple" />}
showChevron
haptic="light"
onPress={onOpenDocs}
/>
</List.Section>
</List>
</Mount>
</ScrollView>
</SafeAreaView>
);
}
HelpCenterScreen.displayName = "HelpCenterScreen";
Recreate the "Help Center Screen" screen in React Native using LumioUI.
Goal: Search-first support — filterable FAQ accordion with live result count, plus chat/email contact rows and a docs link Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add core-help-center
```
Or pull just the components: `npx lumioui add accordion button empty-state icon list search-input text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `accordion` — Expandable sections with spring height animation and haptics.
- `<Accordion>` ×1 — AccordionTrigger fires its own light haptic — don't double it.
- `<AccordionItem>` ×1
- `<AccordionTrigger>` ×1
- `<AccordionContent>` ×1
### `button` — Tactile pill button with press spring, ink wash and async loading.
- `<Button>` ×1
### `empty-state` — Illustrated placeholder with title, copy and action slot.
- `<EmptyState>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×1
### `list` — Settings-style rows with separators and press feedback.
- `<List>` ×1
### `search-input` — Search field with clear button, debounce, focus halo — plus a glass capsule mode that floats over content like iOS 26
- `<SearchInput>` ×1
### `text` — Themed type scale from caption to display.
- `<Text>` ×5
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/core/help-center.tsx` exporting HelpCenterScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { useMemo, useState } from "react";
import { View, ScrollView } from "react-native";
import { SafeAreaView, useSafeAreaInsets } from "react-native-safe-area-context";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, Button, EmptyState, Glass, Icon, List, SearchInput, Text } from "@lumioui/ui";
import { BLOCK_STAGGER, LumioBot, Mount, RowIcon, SEPARATOR_INSET } from "../shared";
import { useHaptic } from "@lumioui/core";
export interface HelpArticle {
id: string;
question: string;
answer: string;
}
export interface HelpCenterScreenProps {
articles?: HelpArticle[];
onSearch?: (query: string) => void;
onContactChat?: () => void;
onContactEmail?: () => void;
onOpenDocs?: () => void;
onBack?: () => void;
}
const TOPICS = [
{ icon: "package", label: "Orders & returns", tone: "orange" },
{ icon: "credit-card", label: "Billing", tone: "blue" },
{ icon: "settings", label: "Account", tone: "purple" },
{ icon: "shield", label: "Privacy & safety", tone: "green" },
] as const;
const DEFAULT_ARTICLES: HelpArticle[] = [
{
id: "track-order",
question: "Where is my order?",
answer:
"Open Orders and tap Track on the active order — you'll see every checkpoint from the fulfillment center to your door, plus a live ETA once the courier is on the way.",
},
{
id: "returns",
question: "How do I return an item?",
answer:
"Orders → select the order → Start a return. You have 30 days from delivery. The prepaid label lands in your email within a minute, and the refund posts 3–5 days after the carrier scans it.",
},
{
id: "sign-in",
question: "How do I change my email or password?",
answer:
"Settings → Sign-in & security. Email changes need a quick verification code; password changes sign out your other devices automatically.",
},
{
id: "pro",
question: "What's included in Lumio Pro?",
answer:
"Every Pro component and screen template, priority support, and the weekly release train. Manage or cancel anytime under Settings → Subscription.",
},
{
id: "notifications",
question: "Why am I not getting notifications?",
answer:
"Check Settings → Push notifications first, then confirm system-level permission in your phone's settings. Order updates always land in-app under Notifications too.",
},
{
id: "delete-account",
question: "How do I delete my account?",
answer:
"Settings → Sign-in & security → Delete account. Your data is removed within 30 days. Active orders still ship, but the change can't be undone.",
},
];
/** Help center — search-first FAQ accordion with contact + docs rows. */
export function HelpCenterScreen({
articles = DEFAULT_ARTICLES,
onSearch,
onContactChat,
onContactEmail,
onOpenDocs,
onBack,
}: HelpCenterScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const [query, setQuery] = useState("");
const results = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return articles;
return articles.filter(
(a) =>
a.question.toLowerCase().includes(q) || a.answer.toLowerCase().includes(q)
);
}, [articles, query]);
const updateQuery = (text: string) => {
setQuery(text);
onSearch?.(text);
};
return (
<SafeAreaView className="flex-1 bg-[#F4F5F9] dark:bg-[#111114]" edges={["top"]}>
{/* Floating chrome — back pill + hug-label title pill. Search is the
page's own field below, so the header stays to the two pills. */}
<View className="absolute inset-x-4 z-10" style={{ top: insets.top + 8 }} pointerEvents="box-none">
<View className="flex-row items-center gap-2">
<Glass
effect="regular"
className="h-11 w-11 items-center justify-center rounded-full"
>
<Button
variant="ghost"
size="icon"
haptic="light"
onPress={() => onBack?.()}
accessibilityLabel="Go back"
className="h-9 w-9"
>
<Icon name="chevron-left" size="md" color="foreground" />
</Button>
</Glass>
</View>
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<Glass
effect="regular"
className="h-11 flex-row items-center justify-center rounded-full px-5"
>
<Text accessibilityRole="header" className="text-sm font-bold tracking-tight">
Help center
</Text>
</Glass>
</View>
</View>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
contentContainerStyle={{ paddingTop: insets.top + 60, paddingBottom: insets.bottom + 32 }}
>
{/* Search — filters the FAQ below as you type. */}
<Mount className="px-6 pb-4 pt-2">
<SearchInput
placeholder="Search help topics"
value={query}
onChangeText={updateQuery}
onClear={() => updateQuery("")}
accessibilityLabel="Search help topics"
glass
/>
</Mount>
{/* Status line — help centers lead with "is it us or you?" */}
{!query.trim() ? (
<View className="mx-6 mb-4 flex-row items-center gap-2 rounded-xl bg-white px-3.5 py-2.5 dark:bg-[#1A1A20]">
<View className="h-2 w-2 rounded-full bg-success" />
<Text variant="small" className="flex-1 text-xs">
All systems operational
</Text>
<Text variant="muted" className="text-xs">
Status
</Text>
</View>
) : null}
{/* Topic tiles — the Intercom browse idiom: pick a lane before
reading the FAQ. */}
{!query.trim() ? (
<Mount delay={60}>
<View className="flex-row flex-wrap gap-3 px-6 pb-6">
{TOPICS.map((t) => (
<View key={t.label} className="w-[48%] flex-row items-center gap-3 rounded-2xl bg-white p-3.5 dark:bg-[#1A1A20]">
<RowIcon name={t.icon} tone={t.tone} />
<Text className="text-sm font-semibold">{t.label}</Text>
</View>
))}
</View>
</Mount>
) : null}
{results.length === 0 ? (
<EmptyState
variant="search"
title="No articles found"
description={`Nothing matches "${query.trim()}" — try a different term or reach out below.`}
className="px-6 py-10"
/>
) : (
<Mount delay={BLOCK_STAGGER}>
<View className="px-6">
<Text
variant="muted"
accessibilityRole="header"
className="pb-2 text-xs font-semibold uppercase tracking-wider"
>
{query.trim() ? `${results.length} result${results.length === 1 ? "" : "s"}` : "Frequently asked"}
</Text>
{/* AccordionTrigger fires its own light haptic — don't double it. */}
<Accordion
type="single"
collapsible
className="overflow-hidden rounded-3xl bg-white dark:bg-[#1A1A20]"
>
{results.map((article, i) => (
<AccordionItem
key={article.id}
value={article.id}
className={i > 0 ? "border-t border-border" : undefined}
>
<AccordionTrigger className="px-4">
{article.question}
</AccordionTrigger>
<AccordionContent className="px-4">
{article.answer}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</View>
</Mount>
)}
{/* Contact + docs — the escape hatches when the FAQ didn't land. */}
<Mount delay={BLOCK_STAGGER * 2}>
<List inset glass className="pt-2">
<List.Section title="Still need help?">
<List.Item
title="Chat with support"
subtitle="Typically replies in ~5 min"
leading={<LumioBot size={30} />}
showChevron
haptic="light"
onPress={onContactChat}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Email us"
subtitle="support@lumio.dev"
leading={<RowIcon name="mail" tone="teal" />}
showChevron
haptic="light"
onPress={onContactEmail}
/>
<List.Separator className={SEPARATOR_INSET} />
<List.Item
title="Documentation"
subtitle="docs.lumio.dev"
leading={<RowIcon name="book-open" tone="purple" />}
showChevron
haptic="light"
onPress={onOpenDocs}
/>
</List.Section>
</List>
</Mount>
</ScrollView>
</SafeAreaView>
);
}
HelpCenterScreen.displayName = "HelpCenterScreen";
```
Screen: https://lumioui.dev/screens/core-help-center