@acms-subir/native
v0.1.3
Published
React Native / Expo hooks and components for building your own app on an ACMS site.
Readme
@acms-subir/native
React Native / Expo building blocks for your own app on an ACMS site:
a provider, TanStack Query hooks over @acms-subir/client,
theme tokens that follow the site's Design in light and dark mode, and a few
components. ACMS does not ship an app; the fastest start is the blank starter:
npx acms add mobile # from your site folder → mobile/
cd mobile && npm install && cd ..
npx acms mobile dev # site + Expo on an address your phone can reach
npx acms mobile recipe add events # also: news, booking, pushManual install in an existing Expo app:
npx expo install @acms-subir/native @acms-subir/client @tanstack/react-queryPeers: react, react-native (0.74+), @tanstack/react-query v5 and
@acms-subir/client. Optional peers (the starter installs the first two):
| Package | Enables |
| --- | --- |
| react-native-webview | Showing the site's own coded sections as the website renders them on iOS/Android (the web uses an iframe) |
| react-native-svg | Icons in the feature grid section |
| @react-native-async-storage/async-storage, @tanstack/query-async-storage-persister | The offline cache |
Without them everything still bundles and runs (Expo's Metro treats these requires as optional): icons are omitted and unknown sections use the generic view.
Provider
import { AcmsProvider, Sections } from '@acms-subir/native';
export default function App() {
return (
<AcmsProvider baseUrl={process.env.EXPO_PUBLIC_ACMS_URL!}>
<Sections page="home" />
</AcmsProvider>
);
}| Prop | |
| --- | --- |
| baseUrl | Site origin or its /api/v1 URL |
| locale | Initial language; defaults to the site default |
| client, clientOptions | Your own client, or options (fetch, cache, headers, timeout) |
| queryClient | Your own TanStack QueryClient (default: 60s stale time, 7-day gc) |
| persist | Offline cache; see below |
| colorScheme | Force light/dark instead of the device setting |
| loadedFonts | Custom font families you loaded with expo-font; theme fonts use only these |
| onNavigate | (path, url) => void for taps on links to site pages (sections, rich text, web-rendered sections), e.g. to open your own screen. Default: open the URL in the browser. External, mailto: and tel: links always leave the app |
| webSectionFallback | false turns off the web view for sections without a native component (default true) |
The provider preloads /site and /theme. Query keys start with
['acms', baseUrl, locale], so switching language or site never mixes data.
Hooks
| Hook | Returns |
| --- | --- |
| useAcms() | The typed client bound to the current language |
| useSite() | Query: name, menu, languages, features |
| useTheme() | Resolved tokens: scheme, colors (+ onAccent), radius (px, max 12), fonts, styles (space(n), spacing, text.*, card, button, …), ready |
| useLocale() | { locale, defaultLocale, languages, setLocale } |
| usePage(slug = 'home') | Query: a published page with sections |
| usePosts(query) | Infinite query (fetchNextPage), plus flattened, deduplicated items. q gives ranked search results (keeps the previous matches while loading) |
| useSearch(text, { types, collection, limit, debounceMs }) | Query: top matches in posts, pages and events (posts, pages, events, more), debounced; blank text fetches nothing. Also term and pending |
| useDebounced(value, ms = 250) | value once it stops changing, for search boxes |
| useLinks() | Query: the links page (profile, socials, blocks) |
| useForm(id) / useSubmitForm(id) | Query: a published form's questions; mutation that sends answers (the same answers reuse one Idempotency-Key until it succeeds) |
| useManagedBooking(token) | Query: the booker's own booking from its manageToken (can says what they may change); never saved offline |
| useCancelBooking(token) / useRescheduleBooking(token) / useRescheduleAvailability(token, query \| null) | Mutations to cancel or move it, and the open times to move it to |
| useGuest({ storage }) | Guest sign-in: status, email, signOut(), and two mutations, requestCode.mutate(email) and signIn.mutate({ email, code }) (use isPending/error on each for UI state). The token lives in expo-secure-store (sessionStorage on web), one entry per site, and is only ever sent to the site that issued it |
| useMyBookings({ when }) | Query: the signed-in guest's bookings and RSVPs (each with a manageToken); signs out by itself when the session ends |
| usePost(slug, { collection }) | Query: post detail (slug lookup scans post lists) |
| useEvents(query) | Query: occurrences in a window (from, to, calendar, q, limit) |
| useEvent(slug, query) | Query: event found among occurrences in the window |
| useResources() | Query: public resources and booking links |
| useAvailability(query \| null) | Query: open slots; disabled until resource and link are set; never cached offline |
| useBooking({ createKey }) | Mutation. Same details reuse one Idempotency-Key until success; changed details start a new intent. reset() starts over |
| useRefresh() | { refreshing, refresh } for pull to refresh |
| useLayout() | { width, wide, columns, gutter, maxWidth, contentWidth } for phone/tablet layouts |
| useOpenLink() | open(href) for links from section data: site pages go through onNavigate |
| usePushRegistration({ topics, askOnMount }) | { status, token, topics, error, register, setTopics, unregister } for push notifications (needs expo-notifications; see below) |
| usePushTopics() | Query: topics the site offers as notification toggles |
| useNotificationTaps(handler?) | Opens the page a tapped notification points to (also the tap that launched the app) |
Pass createKey (for example expo-crypto's randomUUID) for cryptographic
idempotency keys; the default uses crypto.randomUUID when available. Never put
a booking's manage token or key in a URL or log; use expo-secure-store.
Sections
<Sections page="home" registry={sections} /> renders a page's published
sections top to bottom. Each section uses the first match of:
- your
registry: a type ("hero-banner") or type and variant ("hero-banner:centered") mapped to a component; variant keys win, and an array of registries is searched in order; - the packaged library (
library, default on): native versions of the 11 library sections; - the website's rendering (
webFallback, default on): the section's/embed/section/...document inreact-native-webview(an iframe on the web), sized to its content, with link taps sent toonNavigate; fallback:GenericSection(heading, text, items, links) by default;nullhides the section.
| Prop | |
| --- | --- |
| page | Page slug; "home" is the homepage |
| registry | Your components; they win over everything else |
| library | false to skip the packaged components, or a registry to replace them |
| webFallback | false to use the generic view instead of the web view |
| fallback | Last resort component, or null |
| onNavigate | Overrides the provider's handler for this list |
| loading, renderError, style | Loading/error UI and the content column style |
import { defineSectionRegistry, useTheme, type SectionComponentProps } from '@acms-subir/native';
import type { TeamData } from '@acms-subir/native/sections';
function Team({ data, variant }: SectionComponentProps<TeamData>) { /* ... */ }
export const sections = defineSectionRegistry({ team: Team, 'hero-banner:cover': MyCover });Library sections (@acms-subir/native/sections)
| Type | Component | Variants |
| --- | --- | --- |
| hero-banner | HeroBanner | split, centered, cover |
| service-times | ServiceTimes | cards, strip |
| upcoming-events | UpcomingEvents | cards, list |
| feature-grid | FeatureGrid | three-up, two-up |
| team | Team | grid, compact |
| pricing | Pricing | default |
| gallery | Gallery | grid, strip |
| questions | Questions | accordion, two-column |
| cta-band | CtaBand | accent, quiet |
| contact | Contact | split, stacked |
| latest-posts | LatestPosts | cards, list |
librarySections is the registry <Sections> uses. The components mirror the
website versions (same fields, variants and empty-field behaviour), follow
useTheme() in light and dark mode, and lay out from the window width:
multi-column grids on tablets (like the web's auto-fill/auto-fit grids and
media steps), stacked on phones, no fixed widths. Buttons and links are at
least 44pt tall; corners are rectangular (10–12px at most). Data types
(HeroBannerData, TeamData, EventItem, LinkValue, …) and the building
blocks they use (Grid, FocalImage, SectionHeader, ActionButton,
TextLink, FeatureIcon, useSectionMetrics, useSiteUrl) are exported
from the same entry point for your own sections.
To change one, copy it into your app and edit it there. From the site folder:
npx acms section add team --theme <slug> --native # → mobile/sections/Team.tsx, registeredImages and focal points. Image fields come with <key>Focus ("30% 20%",
as in CSS object-position). FocalImage crops like object-fit: cover at
that point using React Native's Image and Image.getSize (no extra
package). If you prefer expo-image, pass
contentPosition={focusToContentPosition(data.imageFocus)}.
Web view for the site's own sections
Coded sections without a native component (a theme's custom sections/*.astro)
show as the website renders them: WebSectionFallback loads
<site>/embed/section/<page>/<section id>?locale=&scheme=, a chrome-less
document with the site's theme CSS. It posts its height (the view resizes,
for example when a question opens) and hands link taps to onNavigate
instead of navigating; the web view refuses any other top-level navigation.
If the page can't load, the generic view is shown. Turn it off per list
(webFallback={false}) or app-wide (<AcmsProvider webSectionFallback={false}>).
Other components
<RichText html>renders sanitized API HTML (paragraphs, line breaks, bold, italic, links, lists, headings, blockquotes) as native text. Only http(s), mailto and tel links are kept; links useonNavigateunless you passonLinkPress.<EventDate start end allDay timezone>formats in the event's timezone and the current language.Button,ErrorView,GenericSectionare small themed helpers.
Section links and media can be root-relative; resolve them with
resolveUrl(href, baseUrl) or open them with useOpenLink().
Push notifications
Works when the site turns push on (acms({ mobile: { push: true } })) and the
app installs the optional peers:
npx expo install expo-notifications expo-deviceAdd "expo-notifications" to plugins in app.json and an EAS project id
(npx eas-cli init, stored in extra.eas.projectId); Expo push tokens need it.
const push = usePushRegistration({ topics: ['news', 'events'] }); // starting topics for new devices
// push.status: checking | idle | registering | registered | denied | unsupported | error
<Button label="Turn on notifications" onPress={push.register} />
const { data } = usePushTopics(); // toggles: data.topics
push.setTopics(['events']); // saved on the site
useNotificationTaps(); // taps open the page (via onNavigate)Without expo-notifications, on web and on simulators the status is
unsupported and nothing else runs. Once permission is granted the device
re-registers silently on each start (keeping it on the site's list) and sends
the app's language, so notices arrive in the reader's language when the site
has a translation. unregister() opts out.
Offline cache
import { offlinePersister } from '@acms-subir/native/persist';
<AcmsProvider baseUrl={url} persist={offlinePersister({ buster: '1' })}>Successful content reads are saved to AsyncStorage (7 days by default) and shown on the next start while fresh data loads. Availability, bookings and errors are never saved. Any object with TanStack's persister shape also works.
Pure helpers
parseHtml, htmlToText, resolveTheme, resolveScheme, fontFamily,
contrastText, resolveSection, chooseSection, genericSectionView,
classifyLink, sectionEmbedUrl, parseEmbedMessage, parseFocus,
focalCrop, focusToContentPosition, gridColumns, cellWidth,
stepColumns, fluid, contentWidth, formatEventDate, formatClock and
localDay have no React Native dependency and are unit tested:
npm run build -w packages/native
npm run test -w packages/nativeEditor apps: @acms-subir/native/editor
Building blocks for the site's staff app (a separate app from the one
visitors use): sign in through the site (OAuth 2.0 + PKCE in the system
browser; tokens in secure storage, refreshed automatically), hooks over the
bearer-only /api/v1/admin/* API, and inputs for every section field type.
npx acms add mobile editor # from the site folder → mobile-editor/import { EditorProvider, completeAuthRedirect, redirectUriFor, useAuth } from '@acms-subir/native/editor';
completeAuthRedirect(); // web: lets the sign-in popup hand back its result
export default function App() {
return (
<EditorProvider baseUrl={process.env.EXPO_PUBLIC_ACMS_URL!} clientId="org.grace.staff" redirectUri={redirectUriFor('org.grace.staff')}>
<Gate />
</EditorProvider>
);
}
function Gate() {
const auth = useAuth();
return auth.signedIn ? <Home /> : <Button title="Sign in" onPress={() => auth.signIn()} />;
}Optional peers, loaded only when installed: expo-secure-store,
expo-web-browser, expo-crypto, expo-image-picker, expo-device
(expo-auth-session is not required). Hooks: useAuth, useSiteInfo, useMe,
useDocuments, useCreateDocument, useDocument (optimistic concurrency with
conflict / keepMine() / keepTheirs()), usePublish, useSectionLayouts,
useSectionEditor (canEdit(fieldKey), changed-fields-only saves, publish one
section), useTranslations, useTranslationQueue, useMedia
(pickAndUpload), useBookingsInbox, useEditorClient. Components:
FieldInput, SectionFields, MediaPicker, DocumentPicker and Inset Paper
building blocks (EditorButton, EditorInput, Segmented, Notice,
StatusText, ListRow, Card, Sheet, OptionGrid; useEditorTheme()).
Pure helpers (field rules shared with the server, permissions, list rows,
markdown-lite rich text, token storage, the session controller) are exported
for your own UI and tests. Full guide: npx acms docs mobile → Editor apps.
