@acms-subir/core 0.3.3-next.20260929113209 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (251) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/cli/common.mjs +2 -2
  3. package/cli/legacy.mjs +1 -1
  4. package/cli/main.mjs +82 -8
  5. package/cli/mobile-doctor.mjs +430 -0
  6. package/cli/mobile.mjs +315 -0
  7. package/cli/native-sections.mjs +168 -0
  8. package/cli/sections.mjs +186 -0
  9. package/cli/themes.mjs +52 -8
  10. package/docs/mobile.md +527 -0
  11. package/docs/sections.md +203 -0
  12. package/docs/theme-authoring.md +1 -1
  13. package/integration.mjs +55 -1
  14. package/mobile-template/client/AGENTS.md +97 -0
  15. package/mobile-template/client/README.md +65 -0
  16. package/mobile-template/client/app/_layout.tsx +56 -0
  17. package/mobile-template/client/app/index.tsx +21 -0
  18. package/mobile-template/client/app.json +21 -0
  19. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  20. package/mobile-template/client/eas.json +21 -0
  21. package/mobile-template/client/env.example +3 -0
  22. package/mobile-template/client/gitignore +42 -0
  23. package/mobile-template/client/package.json +38 -0
  24. package/mobile-template/client/sections/index.ts +11 -0
  25. package/mobile-template/client/tsconfig.json +7 -0
  26. package/mobile-template/editor/AGENTS.md +66 -0
  27. package/mobile-template/editor/README.md +73 -0
  28. package/mobile-template/editor/app/_layout.tsx +76 -0
  29. package/mobile-template/editor/app/auth.tsx +13 -0
  30. package/mobile-template/editor/app/index.tsx +55 -0
  31. package/mobile-template/editor/app.json +21 -0
  32. package/mobile-template/editor/components/Screen.tsx +51 -0
  33. package/mobile-template/editor/components/SignIn.tsx +36 -0
  34. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  35. package/mobile-template/editor/eas.json +21 -0
  36. package/mobile-template/editor/env.example +5 -0
  37. package/mobile-template/editor/gitignore +42 -0
  38. package/mobile-template/editor/lib/appearance.tsx +12 -0
  39. package/mobile-template/editor/lib/config.ts +17 -0
  40. package/mobile-template/editor/lib/format.ts +24 -0
  41. package/mobile-template/editor/lib/tools.ts +16 -0
  42. package/mobile-template/editor/package.json +40 -0
  43. package/mobile-template/editor/tools/README.md +2 -0
  44. package/mobile-template/editor/tsconfig.json +7 -0
  45. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  46. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  47. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  48. package/mobile-template/recipes/booking/recipe.json +17 -0
  49. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  50. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  51. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  52. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  54. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  55. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  56. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  57. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  58. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  59. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  60. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  62. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  63. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  64. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  65. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  67. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  68. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  69. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  70. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  72. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  73. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  74. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  75. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  76. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  77. package/mobile-template/recipes/events/recipe.json +15 -0
  78. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  79. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  80. package/mobile-template/recipes/news/recipe.json +13 -0
  81. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  82. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  83. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  84. package/mobile-template/recipes/push/recipe.json +20 -0
  85. package/package.json +4 -2
  86. package/section-library/README.md +16 -0
  87. package/section-library/contact/Contact.astro +49 -0
  88. package/section-library/contact/section.json +106 -0
  89. package/section-library/cta-band/CtaBand.astro +28 -0
  90. package/section-library/cta-band/section.json +51 -0
  91. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  92. package/section-library/feature-grid/section.json +107 -0
  93. package/section-library/gallery/Gallery.astro +29 -0
  94. package/section-library/gallery/section.json +51 -0
  95. package/section-library/hero-banner/HeroBanner.astro +42 -0
  96. package/section-library/hero-banner/section.json +81 -0
  97. package/section-library/latest-posts/LatestPosts.astro +22 -0
  98. package/section-library/latest-posts/section.json +37 -0
  99. package/section-library/pricing/Pricing.astro +40 -0
  100. package/section-library/pricing/section.json +117 -0
  101. package/section-library/questions/Questions.astro +38 -0
  102. package/section-library/questions/section.json +69 -0
  103. package/section-library/service-times/ServiceTimes.astro +42 -0
  104. package/section-library/service-times/section.json +127 -0
  105. package/section-library/team/Team.astro +45 -0
  106. package/section-library/team/section.json +88 -0
  107. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  108. package/section-library/upcoming-events/section.json +51 -0
  109. package/src/actions/index.ts +19 -171
  110. package/src/components/AiSettings.tsx +45 -5
  111. package/src/components/DesignManager.tsx +112 -16
  112. package/src/components/MobileApp.tsx +392 -0
  113. package/src/components/ThemeSections.astro +40 -21
  114. package/src/components/ThemeSectionsEditor.tsx +757 -229
  115. package/src/components/editor/PostEditor.tsx +174 -10
  116. package/src/components/import/ImportExport.tsx +4 -1
  117. package/src/components/import/runner.ts +1 -0
  118. package/src/components/sections/SectionFields.tsx +560 -0
  119. package/src/components/sections/SectionPicker.tsx +120 -0
  120. package/src/components/sections/TranslationPanel.tsx +143 -0
  121. package/src/components/sections/checks.ts +64 -0
  122. package/src/layouts/Admin.astro +6 -1
  123. package/src/layouts/Site.astro +107 -19
  124. package/src/lib/agent-discovery.ts +11 -7
  125. package/src/lib/agent.ts +35 -2
  126. package/src/lib/ai-schema.ts +5 -0
  127. package/src/lib/ai.ts +5 -0
  128. package/src/lib/api.ts +12 -0
  129. package/src/lib/content-service.ts +198 -0
  130. package/src/lib/design-service.ts +153 -5
  131. package/src/lib/drafts.ts +3 -3
  132. package/src/lib/editor/auth.ts +264 -0
  133. package/src/lib/editor/bookings.ts +60 -0
  134. package/src/lib/editor/config.ts +25 -0
  135. package/src/lib/editor/documents.ts +227 -0
  136. package/src/lib/editor/http.ts +48 -0
  137. package/src/lib/editor/me.ts +30 -0
  138. package/src/lib/editor/migration.ts +57 -0
  139. package/src/lib/editor/next.ts +11 -0
  140. package/src/lib/editor/openapi.ts +124 -0
  141. package/src/lib/editor/schemas.ts +91 -0
  142. package/src/lib/editor/sections.ts +96 -0
  143. package/src/lib/editor/store.ts +184 -0
  144. package/src/lib/editor/token-route.ts +74 -0
  145. package/src/lib/editor/translations.ts +163 -0
  146. package/src/lib/email/accounts.ts +2 -1
  147. package/src/lib/email/types.ts +2 -1
  148. package/src/lib/home-source.ts +34 -0
  149. package/src/lib/i18n.ts +34 -0
  150. package/src/lib/import/apply.ts +31 -2
  151. package/src/lib/import/bundle.ts +4 -0
  152. package/src/lib/import/export.ts +1 -0
  153. package/src/lib/import/ghost.ts +1 -0
  154. package/src/lib/media/upload.ts +83 -0
  155. package/src/lib/mobile/associations.ts +25 -0
  156. package/src/lib/mobile/calendar.ts +19 -11
  157. package/src/lib/mobile/config.mjs +59 -0
  158. package/src/lib/mobile/content.ts +18 -13
  159. package/src/lib/mobile/embed.ts +83 -0
  160. package/src/lib/mobile/http.ts +8 -6
  161. package/src/lib/mobile/locale.ts +18 -0
  162. package/src/lib/mobile/openapi.ts +111 -0
  163. package/src/lib/mobile/pages.ts +71 -0
  164. package/src/lib/mobile/route.ts +3 -3
  165. package/src/lib/mobile/schemas.ts +60 -0
  166. package/src/lib/mobile/site.ts +41 -0
  167. package/src/lib/push/api.ts +158 -0
  168. package/src/lib/push/config.ts +20 -0
  169. package/src/lib/push/expo.ts +76 -0
  170. package/src/lib/push/migration.ts +60 -0
  171. package/src/lib/push/service.ts +302 -0
  172. package/src/lib/push/store.ts +144 -0
  173. package/src/lib/push/types.ts +43 -0
  174. package/src/lib/repo/migrations.ts +11 -0
  175. package/src/lib/repo/sql.ts +40 -7
  176. package/src/lib/repo/types.ts +9 -3
  177. package/src/lib/roles.ts +5 -0
  178. package/src/lib/scheduler.ts +3 -0
  179. package/src/lib/schema.ts +27 -1
  180. package/src/lib/section-ai.ts +55 -0
  181. package/src/lib/section-targets.ts +62 -0
  182. package/src/lib/sections-agent.ts +146 -0
  183. package/src/lib/sections.ts +217 -0
  184. package/src/lib/theme.ts +16 -1
  185. package/src/lib/translate.ts +97 -0
  186. package/src/lib/translations.ts +39 -0
  187. package/src/middleware.ts +32 -0
  188. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  189. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  190. package/src/pages/[...slug].astro +8 -3
  191. package/src/pages/admin/app-auth.astro +159 -0
  192. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  193. package/src/pages/admin/calendar/events/[id].astro +9 -0
  194. package/src/pages/admin/login.astro +6 -2
  195. package/src/pages/admin/logout.ts +5 -2
  196. package/src/pages/admin/mobile.astro +18 -0
  197. package/src/pages/admin/pages/[id].astro +62 -0
  198. package/src/pages/admin/section-preview.astro +61 -0
  199. package/src/pages/admin/users.astro +107 -11
  200. package/src/pages/api/agent/docs.ts +6 -2
  201. package/src/pages/api/agent/index.ts +1 -1
  202. package/src/pages/api/ai/settings.ts +7 -4
  203. package/src/pages/api/design/ai.ts +15 -0
  204. package/src/pages/api/design/index.ts +12 -42
  205. package/src/pages/api/design/translate.ts +15 -0
  206. package/src/pages/api/live/thumb.ts +1 -1
  207. package/src/pages/api/mcp.ts +2 -2
  208. package/src/pages/api/media/upload.ts +10 -78
  209. package/src/pages/api/push/admin.ts +27 -0
  210. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  211. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  212. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  213. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  214. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  215. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  216. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  217. package/src/pages/api/v1/admin/me.ts +6 -0
  218. package/src/pages/api/v1/admin/media/index.ts +32 -0
  219. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  220. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  221. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  222. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  223. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  224. package/src/pages/api/v1/auth/revoke.ts +5 -0
  225. package/src/pages/api/v1/auth/token.ts +5 -0
  226. package/src/pages/api/v1/devices/[token].ts +20 -0
  227. package/src/pages/api/v1/devices/index.ts +17 -0
  228. package/src/pages/api/v1/openapi.json.ts +7 -0
  229. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  230. package/src/pages/api/v1/pages/home.ts +7 -0
  231. package/src/pages/api/v1/push/topics.ts +7 -0
  232. package/src/pages/api/v1/site.ts +7 -0
  233. package/src/pages/api/v1/theme.ts +7 -0
  234. package/src/pages/blog/[slug].astro +8 -2
  235. package/src/pages/blog/index.astro +11 -4
  236. package/src/pages/embed/section/[...path].astro +42 -0
  237. package/src/pages/events/[slug].astro +7 -2
  238. package/src/pages/index.astro +11 -5
  239. package/src/pages/sitemap.xml.ts +8 -0
  240. package/src/styles/admin.css +69 -0
  241. package/src/styles/editor.css +21 -0
  242. package/src/styles/mobile-app.css +151 -0
  243. package/src/styles/sections.css +7 -1
  244. package/src/styles/theme-editor.css +255 -0
  245. package/src/theme-sdk/contract.d.mts +2193 -89
  246. package/src/theme-sdk/contract.mjs +77 -20
  247. package/src/theme-sdk/fields.d.mts +88 -0
  248. package/src/theme-sdk/fields.mjs +326 -0
  249. package/src/theme-sdk/index.ts +10 -2
  250. package/src/theme-sdk/tokens.d.mts +11 -0
  251. package/src/virtual.d.ts +1 -0
@@ -0,0 +1,41 @@
1
+ import { ActivityIndicator, Image, Linking, ScrollView, Text, View } from 'react-native';
2
+ import { Stack, useLocalSearchParams } from 'expo-router';
3
+ import { Button, ErrorView, EventDate, resolveUrl, useAcmsContext, useEvent, useLayout, useTheme } from '@acms-subir/native';
4
+ import { addToCalendar } from '../../lib/addToCalendar';
5
+
6
+ export default function EventScreen() {
7
+ const t = useTheme();
8
+ const { baseUrl } = useAcmsContext();
9
+ const { gutter } = useLayout();
10
+ const params = useLocalSearchParams<{ slug: string; start?: string; end?: string; allDay?: string }>();
11
+ // Search from the chosen occurrence's day so later occurrences are found too.
12
+ const from = params.start ? params.start.slice(0, 10) : undefined;
13
+ const { data: event, error, isPending, refetch } = useEvent(params.slug, from ? { from } : {});
14
+ if (error && !event) return <ErrorView error={error} retry={refetch} />;
15
+ if (isPending || !event) return <ActivityIndicator style={{ marginTop: 48 }} color={t.colors.accent} />;
16
+ const occurrence = { start: params.start ?? event.start, end: params.end ?? event.end, allDay: params.start ? params.allDay === '1' : event.allDay };
17
+ const image = resolveUrl(event.image, baseUrl);
18
+ const cta = event.cta.label ? resolveUrl(event.cta.href, baseUrl) : undefined;
19
+ return (
20
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 760, alignSelf: 'center' }}>
21
+ <Stack.Screen options={{ title: event.title }} />
22
+ {image && <Image source={{ uri: image }} accessibilityLabel={event.imageAlt} style={{ width: '100%', aspectRatio: 16 / 9, borderRadius: t.radius, backgroundColor: t.colors.soft }} />}
23
+ <EventDate {...occurrence} timezone={event.timezone} style={{ color: t.colors.accent }} />
24
+ <Text accessibilityRole="header" style={t.styles.text.display}>{event.title}</Text>
25
+ {event.cancelled && <Text style={t.styles.text.heading}>This event is cancelled.</Text>}
26
+ {!!event.repeats && <Text style={t.styles.text.small}>{event.repeats}</Text>}
27
+ {!!(event.location.name || event.location.address) && (
28
+ <View style={{ gap: 2 }}>
29
+ {!!event.location.name && <Text style={t.styles.text.body}>{event.location.name}</Text>}
30
+ {!!event.location.address && <Text style={t.styles.text.muted}>{event.location.address}</Text>}
31
+ </View>
32
+ )}
33
+ {!!event.excerpt && <Text style={t.styles.text.body}>{event.excerpt}</Text>}
34
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}>
35
+ {!event.cancelled && <Button label="Add to calendar" onPress={() => addToCalendar(event, occurrence)} />}
36
+ {cta && <Button label={event.cta.label} variant="outline" onPress={() => Linking.openURL(cta)} />}
37
+ <Button label="Open on website" variant="outline" onPress={() => Linking.openURL(event.url)} />
38
+ </View>
39
+ </ScrollView>
40
+ );
41
+ }
@@ -0,0 +1,50 @@
1
+ import { FlatList, Pressable, RefreshControl, Text, View } from 'react-native';
2
+ import { Stack, router } from 'expo-router';
3
+ import { EventDate, ErrorView, useEvents, useLayout, useRefresh, useTheme } from '@acms-subir/native';
4
+
5
+ // Next 60 days of events from the site's public calendars.
6
+ const DAYS = 60;
7
+ // Calendar days on this phone (the site reads them in its own time zone), not UTC:
8
+ // in the evening UTC is already tomorrow, which would skip tonight's events.
9
+ const day = (d: Date) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
10
+ const today = () => day(new Date());
11
+ const plusDays = (n: number) => { const d = new Date(); d.setDate(d.getDate() + n); return day(d); };
12
+
13
+ export default function EventsScreen() {
14
+ const t = useTheme();
15
+ const { columns, gutter, maxWidth } = useLayout();
16
+ const { refreshing, refresh } = useRefresh();
17
+ const { data, isPending, error, refetch } = useEvents({ from: today(), to: plusDays(DAYS), limit: 50 });
18
+ const cols = Math.min(columns, 2);
19
+ return (
20
+ <>
21
+ <Stack.Screen options={{ title: 'Events' }} />
22
+ {error && !data ? <ErrorView error={error} retry={refetch} /> : (
23
+ <FlatList
24
+ key={cols}
25
+ numColumns={cols}
26
+ data={data?.occurrences ?? []}
27
+ keyExtractor={(o) => o.id}
28
+ style={{ backgroundColor: t.colors.bg }}
29
+ contentContainerStyle={{ padding: gutter, gap: 12, width: '100%', maxWidth, alignSelf: 'center' }}
30
+ columnWrapperStyle={cols > 1 ? { gap: 12 } : undefined}
31
+ refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={t.colors.muted} />}
32
+ ListEmptyComponent={isPending ? null : <Text style={t.styles.text.muted}>Nothing scheduled right now.</Text>}
33
+ renderItem={({ item: o }) => (
34
+ <Pressable
35
+ accessibilityRole="button"
36
+ onPress={() => router.push({ pathname: '/events/[slug]', params: { slug: o.event.slug, start: o.start, end: o.end, allDay: o.allDay ? '1' : '' } })}
37
+ style={({ pressed }) => [t.styles.card, { flex: 1, gap: 4, opacity: pressed ? 0.85 : 1 }]}
38
+ >
39
+ <EventDate start={o.start} end={o.end} allDay={o.allDay} timezone={o.event.timezone} style={{ color: t.colors.accent }} />
40
+ <Text style={[t.styles.text.heading, o.event.cancelled && { textDecorationLine: 'line-through' }]}>{o.event.title}</Text>
41
+ {!!o.event.location.name && <Text style={t.styles.text.small}>{o.event.location.name}</Text>}
42
+ {o.event.cancelled && <Text style={[t.styles.text.label, { color: t.colors.ink }]}>Cancelled</Text>}
43
+ </Pressable>
44
+ )}
45
+ ListFooterComponent={<View style={{ height: 24 }} />}
46
+ />
47
+ )}
48
+ </>
49
+ );
50
+ }
@@ -0,0 +1,27 @@
1
+ import { Platform, Share } from 'react-native';
2
+ import * as Calendar from 'expo-calendar';
3
+ import type { Event } from '@acms-subir/client';
4
+
5
+ // Opens the system "add event" sheet (no calendar permission needed). Where
6
+ // that is unavailable (web, older devices) it falls back to sharing the event.
7
+ export async function addToCalendar(event: Event, occurrence: { start: string; end: string; allDay: boolean }) {
8
+ const details = {
9
+ title: event.title,
10
+ startDate: new Date(occurrence.start),
11
+ endDate: new Date(occurrence.end),
12
+ allDay: occurrence.allDay,
13
+ location: [event.location.name, event.location.address].filter(Boolean).join(', '),
14
+ notes: [event.excerpt, event.url].filter(Boolean).join('\n\n'),
15
+ url: event.url,
16
+ timeZone: event.timezone,
17
+ };
18
+ if (Platform.OS !== 'web') {
19
+ try {
20
+ await Calendar.createEventInCalendarAsync(details);
21
+ return;
22
+ } catch {
23
+ // fall through to sharing
24
+ }
25
+ }
26
+ await Share.share({ title: event.title, message: [event.title, details.location, event.url].filter(Boolean).join('\n'), url: event.icsUrl || event.url });
27
+ }
@@ -0,0 +1,15 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "Events",
4
+ "description": "Upcoming events list, event detail and add to calendar (with a share fallback).",
5
+ "packages": [
6
+ "expo-calendar"
7
+ ],
8
+ "routes": [
9
+ "/events",
10
+ "/events/[slug]"
11
+ ],
12
+ "next": [
13
+ "Link to it from a screen: <Link href=\"/events\">Events</Link> (expo-router)"
14
+ ]
15
+ }
@@ -0,0 +1,25 @@
1
+ import { ActivityIndicator, Image, Linking, ScrollView, Text } from 'react-native';
2
+ import { Stack, useLocalSearchParams } from 'expo-router';
3
+ import { Button, ErrorView, RichText, resolveUrl, useAcmsContext, useLayout, useLocale, usePost, useTheme } from '@acms-subir/native';
4
+
5
+ export default function ArticleScreen() {
6
+ const t = useTheme();
7
+ const { baseUrl } = useAcmsContext();
8
+ const { locale } = useLocale();
9
+ const { gutter } = useLayout();
10
+ const { slug, collection } = useLocalSearchParams<{ slug: string; collection?: string }>();
11
+ const { data: post, error, isPending, refetch } = usePost(slug, collection ? { collection } : {});
12
+ if (error && !post) return <ErrorView error={error} retry={refetch} />;
13
+ if (isPending || !post) return <ActivityIndicator style={{ marginTop: 48 }} color={t.colors.accent} />;
14
+ const image = resolveUrl(post.image, baseUrl);
15
+ return (
16
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 720, alignSelf: 'center' }}>
17
+ <Stack.Screen options={{ title: '' }} />
18
+ <Text accessibilityRole="header" style={t.styles.text.display}>{post.title}</Text>
19
+ {!!post.publishedAt && <Text style={t.styles.text.small}>{new Date(post.publishedAt).toLocaleDateString(locale, { year: 'numeric', month: 'long', day: 'numeric' })}</Text>}
20
+ {image && <Image source={{ uri: image }} accessibilityLabel={post.imageAlt} style={{ width: '100%', aspectRatio: 16 / 9, borderRadius: t.radius, backgroundColor: t.colors.soft }} />}
21
+ <RichText html={post.content.html} />
22
+ <Button label="Open on website" variant="outline" onPress={() => Linking.openURL(post.url)} />
23
+ </ScrollView>
24
+ );
25
+ }
@@ -0,0 +1,50 @@
1
+ import { ActivityIndicator, FlatList, Image, Pressable, RefreshControl, Text } from 'react-native';
2
+ import { Stack, router } from 'expo-router';
3
+ import { ErrorView, resolveUrl, useAcmsContext, useLayout, useLocale, usePosts, useRefresh, useTheme } from '@acms-subir/native';
4
+
5
+ export default function NewsScreen() {
6
+ const t = useTheme();
7
+ const { baseUrl } = useAcmsContext();
8
+ const { locale } = useLocale();
9
+ const { columns, gutter, maxWidth } = useLayout();
10
+ const { refreshing, refresh } = useRefresh();
11
+ // Pass { collection: 'blog' } to show a single collection.
12
+ const posts = usePosts({ limit: 20 });
13
+ const cols = Math.min(columns, 3);
14
+ const date = (iso: string | null) => (iso ? new Date(iso).toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' }) : '');
15
+ if (posts.error && !posts.data) return <ErrorView error={posts.error} retry={() => posts.refetch()} />;
16
+ return (
17
+ <>
18
+ <Stack.Screen options={{ title: 'News' }} />
19
+ <FlatList
20
+ key={cols}
21
+ numColumns={cols}
22
+ data={posts.items}
23
+ keyExtractor={(p) => p.id}
24
+ style={{ backgroundColor: t.colors.bg }}
25
+ contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth, alignSelf: 'center' }}
26
+ columnWrapperStyle={cols > 1 ? { gap: 16 } : undefined}
27
+ refreshControl={<RefreshControl refreshing={refreshing} onRefresh={refresh} tintColor={t.colors.muted} />}
28
+ onEndReachedThreshold={0.5}
29
+ onEndReached={() => { if (posts.hasNextPage && !posts.isFetchingNextPage) posts.fetchNextPage(); }}
30
+ ListEmptyComponent={posts.isPending ? <ActivityIndicator color={t.colors.accent} /> : <Text style={t.styles.text.muted}>No posts yet.</Text>}
31
+ ListFooterComponent={posts.isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} color={t.colors.accent} /> : null}
32
+ renderItem={({ item }) => {
33
+ const image = resolveUrl(item.image, baseUrl);
34
+ return (
35
+ <Pressable
36
+ accessibilityRole="button"
37
+ onPress={() => router.push({ pathname: '/news/[slug]', params: { slug: item.slug, collection: item.collection ?? '' } })}
38
+ style={({ pressed }) => [t.styles.card, { flex: 1, gap: 8, opacity: pressed ? 0.85 : 1 }]}
39
+ >
40
+ {image && <Image source={{ uri: image }} accessibilityLabel={item.imageAlt} style={{ width: '100%', aspectRatio: 16 / 9, borderRadius: Math.max(0, t.radius - 2), backgroundColor: t.colors.soft }} />}
41
+ <Text style={t.styles.text.small}>{date(item.publishedAt)}</Text>
42
+ <Text style={t.styles.text.heading}>{item.title}</Text>
43
+ {!!item.excerpt && <Text style={t.styles.text.muted} numberOfLines={3}>{item.excerpt}</Text>}
44
+ </Pressable>
45
+ );
46
+ }}
47
+ />
48
+ </>
49
+ );
50
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "News",
4
+ "description": "Posts list with infinite scroll and an article screen with rich text.",
5
+ "packages": [],
6
+ "routes": [
7
+ "/news",
8
+ "/news/[slug]"
9
+ ],
10
+ "next": [
11
+ "Link to it from a screen: <Link href=\"/news\">News</Link> (expo-router)"
12
+ ]
13
+ }
@@ -0,0 +1,64 @@
1
+ import { Linking, ScrollView, Switch, Text, View } from 'react-native';
2
+ import { Stack } from 'expo-router';
3
+ import { Button, useLayout, usePushRegistration, usePushTopics, useTheme } from '@acms-subir/native';
4
+ import { STARTING_TOPICS } from '../lib/push';
5
+
6
+ // Turn notifications on, choose topics, or turn them off. Topics and their
7
+ // names come from the site, so new calendars or collections appear here
8
+ // without an app update.
9
+ export default function NotificationsScreen() {
10
+ const t = useTheme();
11
+ const { gutter } = useLayout();
12
+ const push = usePushRegistration({ topics: STARTING_TOPICS });
13
+ const offered = usePushTopics();
14
+ const toggle = (id: string, on: boolean) => {
15
+ const next = on ? [...push.topics, id] : push.topics.filter((x) => x !== id);
16
+ push.setTopics(next).catch(() => {});
17
+ };
18
+ return (
19
+ <ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
20
+ <Stack.Screen options={{ title: 'Notifications' }} />
21
+ <Text accessibilityRole="header" style={t.styles.text.title}>Stay in the loop</Text>
22
+ <Text style={t.styles.text.muted}>Get a notification when something new is posted or an event is about to start.</Text>
23
+
24
+ {push.status === 'checking' || push.status === 'registering' ? (
25
+ <Text style={t.styles.text.small}>One moment…</Text>
26
+ ) : push.status === 'idle' ? (
27
+ <Button label="Turn on notifications" onPress={() => { push.register(); }} />
28
+ ) : push.status === 'denied' ? (
29
+ <View style={[t.styles.card, { gap: 12 }]}>
30
+ <Text style={t.styles.text.body}>Notifications are off for this app. Turn them on in Settings.</Text>
31
+ <Button variant="outline" label="Open Settings" onPress={() => { Linking.openSettings().catch(() => {}); }} />
32
+ </View>
33
+ ) : push.status === 'unsupported' || push.status === 'error' ? (
34
+ <View style={[t.styles.card, { gap: 12 }]}>
35
+ <Text style={t.styles.text.body}>{push.error}</Text>
36
+ {push.status === 'error' && <Button variant="outline" label="Try again" onPress={() => { push.register(); }} />}
37
+ </View>
38
+ ) : (
39
+ <>
40
+ <Text style={t.styles.text.label}>Notify me about</Text>
41
+ <View style={[t.styles.card, { paddingVertical: 4 }]}>
42
+ {offered.isError ? (
43
+ <Text style={[t.styles.text.small, { paddingVertical: 12 }]}>Topics are unavailable right now.</Text>
44
+ ) : !offered.data ? (
45
+ <Text style={[t.styles.text.small, { paddingVertical: 12 }]}>Loading…</Text>
46
+ ) : offered.data.topics.map((topic, i) => (
47
+ <View key={topic.id} style={{ flexDirection: 'row', alignItems: 'center', gap: 12, minHeight: 52, borderTopWidth: i ? 1 : 0, borderTopColor: t.colors.line }}>
48
+ <Text style={[t.styles.text.body, { flex: 1 }]}>{topic.label}</Text>
49
+ <Switch
50
+ accessibilityLabel={topic.label}
51
+ value={push.topics.includes(topic.id)}
52
+ onValueChange={(on) => toggle(topic.id, on)}
53
+ trackColor={{ true: t.colors.accent, false: t.colors.line }}
54
+ />
55
+ </View>
56
+ ))}
57
+ </View>
58
+ <Text style={t.styles.text.small}>Announcements from the site reach everyone with notifications on.</Text>
59
+ <Button variant="outline" label="Turn off notifications" onPress={() => { push.unregister().catch(() => {}); }} />
60
+ </>
61
+ )}
62
+ </ScrollView>
63
+ );
64
+ }
@@ -0,0 +1,15 @@
1
+ import { usePushRegistration, useNotificationTaps } from '@acms-subir/native';
2
+ import { STARTING_TOPICS } from '../lib/push';
3
+
4
+ // Mount once inside <AcmsProvider> (app/_layout.tsx):
5
+ // <AcmsProvider baseUrl={baseUrl} onNavigate={...}><PushSetup /><ThemedStack /></AcmsProvider>
6
+ // - If notifications are already on, re-registers quietly on each start (the
7
+ // site removes devices that stop checking in) and keeps the language current.
8
+ // It never asks for permission; the /notifications screen does.
9
+ // - Tapped notifications open their page: site pages go to the provider's
10
+ // onNavigate (route /events/<slug> to your own screens there), else the browser.
11
+ export function PushSetup() {
12
+ usePushRegistration({ topics: STARTING_TOPICS });
13
+ useNotificationTaps();
14
+ return null;
15
+ }
@@ -0,0 +1,15 @@
1
+ import * as Notifications from 'expo-notifications';
2
+
3
+ // Show notifications while the app is open, too (the site sends a title,
4
+ // message and a link in data.url / data.path).
5
+ Notifications.setNotificationHandler({
6
+ handleNotification: async () => ({ shouldShowBanner: true, shouldShowList: true, shouldPlaySound: true, shouldSetBadge: false }),
7
+ });
8
+
9
+ /**
10
+ * Topics a device starts with the first time it registers. Ids come from the
11
+ * site (GET /api/v1/push/topics): "news" = every new post, "events" = every new
12
+ * event, plus "collection:<key>" / "calendar:<slug>" when the site has several.
13
+ * Ones the site doesn't offer are ignored. People change them on /notifications.
14
+ */
15
+ export const STARTING_TOPICS = ['news', 'events'];
@@ -0,0 +1,20 @@
1
+ {
2
+ "kind": "client",
3
+ "title": "Push notifications",
4
+ "description": "Turn-on screen with topic toggles from the site, silent re-registration on each start, and taps that open the page. Needs push turned on for the site.",
5
+ "packages": [
6
+ "expo-notifications",
7
+ "expo-device"
8
+ ],
9
+ "routes": [
10
+ "/notifications"
11
+ ],
12
+ "next": [
13
+ "Turn push on for the site: acms({ mobile: { push: true } }) in astro.config.mjs, then restart or deploy",
14
+ "Add \"expo-notifications\" to the plugins list in app.json",
15
+ "Push tokens need an EAS project id: npx eas-cli init (saves extra.eas.projectId in app.json)",
16
+ "Mount <PushSetup /> inside <AcmsProvider> in app/_layout.tsx (re-registers on start, opens tapped notifications)",
17
+ "Link to the screen: <Link href=\"/notifications\">Notifications</Link> (expo-router)",
18
+ "Send from the site: Admin → Mobile app"
19
+ ]
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@acms-subir/core",
3
- "version": "0.3.3-next.20260929113209",
3
+ "version": "0.3.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -51,7 +51,9 @@
51
51
  "deploy",
52
52
  "ops-check.mjs",
53
53
  "cli",
54
- "docs"
54
+ "docs",
55
+ "section-library",
56
+ "mobile-template"
55
57
  ],
56
58
  "dependencies": {
57
59
  "@cloudflare/workers-types": "^5.20260927.1",
@@ -0,0 +1,16 @@
1
+ # Section library
2
+
3
+ Starter sections for ACMS themes. Copy one into a project theme with:
4
+
5
+ ```sh
6
+ npx acms section add service-times --theme <slug>
7
+ ```
8
+
9
+ Each folder holds `section.json` (the definition merged into `theme.json`, plus
10
+ example data) and one Astro component. Once copied, the section is yours:
11
+ change the markup and CSS freely. Keep the field keys stable (or bump
12
+ `version` and add `migrations/<type>.mjs`) so saved content keeps working.
13
+
14
+ All styling uses the public theme tokens (`--accent`, `--ink`, `--muted`,
15
+ `--panel`, `--soft`, `--line`, `--r`, `--font-heading`, …), so a section
16
+ follows whichever palette, radius and fonts the theme sets.
@@ -0,0 +1,49 @@
1
+ ---
2
+ import type { SectionProps, LinkValue } from '@acms-subir/core/theme';
3
+ interface Data { heading: string; address: string; directions: LinkValue; hours: { days: string; hours: string }[]; phone: string; email: string; image: string; imageFocus: string; alt: string }
4
+ const { data, variant } = Astro.props as SectionProps<Data>;
5
+ const hours = data.hours.filter((h) => h.days || h.hours);
6
+ const tel = data.phone.replace(/[^\d+]/g, '');
7
+ const email = /^[^\s@<>"]+@[^\s@<>"]+\.[^\s@<>"]+$/.test(data.email.trim()) ? data.email.trim() : '';
8
+ ---
9
+ <div class:list={['visit', `is-${variant}`, { 'has-image': data.image }]}>
10
+ <div class="visit-where">
11
+ <h2>{data.heading}</h2>
12
+ {data.address && <address>{data.address}</address>}
13
+ {data.directions?.href && <a class="w-btn outline" href={data.directions.href} rel="noopener">{data.directions.label || 'Get directions'}</a>}
14
+ </div>
15
+ {(hours.length > 0 || tel || email) && (
16
+ <div class="visit-when">
17
+ {hours.length > 0 && (
18
+ <dl class="visit-hours">
19
+ {hours.map((h) => <><dt>{h.days}</dt><dd>{h.hours}</dd></>)}
20
+ </dl>
21
+ )}
22
+ {(tel || email) && (
23
+ <p class="visit-contact">
24
+ {tel && <a href={`tel:${tel}`}>{data.phone}</a>}
25
+ {email && <a href={`mailto:${email}`}>{email}</a>}
26
+ </p>
27
+ )}
28
+ </div>
29
+ )}
30
+ {data.image && <img class="visit-photo" src={data.image} alt={data.alt} loading="lazy" style={`object-position:${data.imageFocus}`} />}
31
+ </div>
32
+ <style>
33
+ /* desktop: where | when (| photo); stacks on narrow screens */
34
+ .visit { display: grid; gap: 1.5rem clamp(2rem, 5vw, 4rem); align-items: start; }
35
+ .visit.is-split { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
36
+ .visit.is-split.has-image { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1.3fr); }
37
+ .visit h2 { margin: 0 0 0.75rem; }
38
+ address { font-style: normal; white-space: pre-line; font-size: 1.1rem; margin-bottom: 1rem; }
39
+ .visit-when { padding: 1.25rem 1.4rem; background: var(--panel); border: 1px solid var(--line); border-radius: var(--r); }
40
+ .visit-hours { display: grid; grid-template-columns: auto 1fr; gap: 0.45rem 1.5rem; margin: 0; }
41
+ dt { color: var(--muted); }
42
+ dd { margin: 0; font-variant-numeric: tabular-nums; }
43
+ .visit-contact { display: flex; flex-wrap: wrap; gap: 0.5rem 1.5rem; margin: 1rem 0 0; padding-top: 1rem; border-top: 1px solid var(--line); }
44
+ .visit-hours:empty + .visit-contact, .visit-when > .visit-contact:first-child { margin-top: 0; padding-top: 0; border-top: 0; }
45
+ .visit-contact a { color: var(--accent-text, var(--accent)); }
46
+ .visit-photo { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--r-lg); }
47
+ @media (max-width: 960px) { .visit.is-split.has-image { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); } .visit.is-split.has-image .visit-photo { grid-column: 1 / -1; } }
48
+ @media (max-width: 680px) { .visit.is-split, .visit.is-split.has-image { grid-template-columns: 1fr; } }
49
+ </style>
@@ -0,0 +1,106 @@
1
+ {
2
+ "type": "contact",
3
+ "definition": {
4
+ "name": "Visit us",
5
+ "version": 1,
6
+ "component": "sections/Contact.astro",
7
+ "description": "Address, opening hours, phone and email, with a directions link.",
8
+ "variants": [
9
+ "split",
10
+ "stacked"
11
+ ],
12
+ "fields": {
13
+ "heading": {
14
+ "label": "Heading",
15
+ "type": "text",
16
+ "default": "Visit us"
17
+ },
18
+ "address": {
19
+ "label": "Address",
20
+ "type": "textarea",
21
+ "default": "",
22
+ "translatable": false
23
+ },
24
+ "directions": {
25
+ "label": "Directions link",
26
+ "type": "link",
27
+ "help": "A Google or Apple Maps link to the address."
28
+ },
29
+ "hours": {
30
+ "label": "Opening hours",
31
+ "type": "list",
32
+ "itemLabel": "days",
33
+ "max": 14,
34
+ "fields": {
35
+ "days": {
36
+ "label": "Days",
37
+ "type": "text",
38
+ "placeholder": "Mon – Fri"
39
+ },
40
+ "hours": {
41
+ "label": "Hours",
42
+ "type": "text",
43
+ "placeholder": "9:00 – 17:00",
44
+ "translatable": false
45
+ }
46
+ }
47
+ },
48
+ "phone": {
49
+ "label": "Phone",
50
+ "type": "text",
51
+ "default": "",
52
+ "translatable": false
53
+ },
54
+ "email": {
55
+ "label": "Email",
56
+ "type": "text",
57
+ "default": "",
58
+ "translatable": false
59
+ },
60
+ "image": {
61
+ "label": "Photo of the building",
62
+ "type": "image",
63
+ "default": ""
64
+ },
65
+ "alt": {
66
+ "label": "Photo description",
67
+ "type": "text",
68
+ "default": ""
69
+ }
70
+ },
71
+ "category": "Info",
72
+ "example": {
73
+ "address": "120 Elm Street\nSpringfield",
74
+ "directions": {
75
+ "label": "Get directions",
76
+ "href": "https://maps.google.com/?q=120+Elm+Street"
77
+ },
78
+ "hours": [
79
+ {
80
+ "days": "Office · Mon – Thu",
81
+ "hours": "9:00 – 16:00"
82
+ }
83
+ ],
84
+ "phone": "(555) 010-2030",
85
+ "email": "hello@example.org"
86
+ }
87
+ },
88
+ "example": {
89
+ "variant": "split",
90
+ "data": {
91
+ "address": "120 Elm Street\nSpringfield",
92
+ "directions": {
93
+ "label": "Get directions",
94
+ "href": "https://maps.google.com/?q=120+Elm+Street"
95
+ },
96
+ "hours": [
97
+ {
98
+ "days": "Office · Mon – Thu",
99
+ "hours": "9:00 – 16:00"
100
+ }
101
+ ],
102
+ "phone": "(555) 010-2030",
103
+ "email": "hello@example.org"
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ import type { SectionProps, LinkValue } from '@acms-subir/core/theme';
3
+ interface Data { heading: string; text: string; primary: LinkValue; secondary: LinkValue }
4
+ const { data, variant } = Astro.props as SectionProps<Data>;
5
+ const buttons = [data.primary, data.secondary].filter((b) => b?.label && b.href);
6
+ ---
7
+ <div class:list={['cta', `is-${variant}`]}>
8
+ <div class="cta-copy">
9
+ <h2>{data.heading}</h2>
10
+ {data.text && <p>{data.text}</p>}
11
+ </div>
12
+ {buttons.length > 0 && (
13
+ <div class="cta-actions">
14
+ {buttons.map((b, i) => <a class:list={['w-btn', i === 0 ? 'primary' : 'outline']} href={b.href}>{b.label}</a>)}
15
+ </div>
16
+ )}
17
+ </div>
18
+ <style>
19
+ .cta { display: flex; flex-wrap: wrap; gap: 1.5rem 3rem; align-items: center; justify-content: space-between; padding: clamp(1.75rem, 5vw, 3rem); border-radius: var(--r-lg); }
20
+ .cta.is-accent { background: var(--accent); color: var(--btn-ink, #fff); }
21
+ .cta.is-quiet { background: var(--soft); }
22
+ .cta-copy { max-width: 40rem; }
23
+ .cta h2 { margin: 0; }
24
+ .cta p { margin: 0.5rem 0 0; opacity: 0.85; }
25
+ .cta-actions { display: flex; flex-wrap: wrap; gap: 0.75rem; }
26
+ .cta.is-accent :global(.w-btn.primary) { background: var(--bg); color: var(--ink); }
27
+ .cta.is-accent :global(.w-btn.outline) { background: transparent; color: inherit; border-color: currentColor; }
28
+ </style>
@@ -0,0 +1,51 @@
1
+ {
2
+ "type": "cta-band",
3
+ "definition": {
4
+ "name": "Call-to-action band",
5
+ "version": 1,
6
+ "component": "sections/CtaBand.astro",
7
+ "description": "A bold band that asks people to do one thing.",
8
+ "variants": [
9
+ "accent",
10
+ "quiet"
11
+ ],
12
+ "fields": {
13
+ "heading": {
14
+ "label": "Heading",
15
+ "type": "text",
16
+ "default": "New here?"
17
+ },
18
+ "text": {
19
+ "label": "Text",
20
+ "type": "textarea",
21
+ "default": ""
22
+ },
23
+ "primary": {
24
+ "label": "Main button",
25
+ "type": "link"
26
+ },
27
+ "secondary": {
28
+ "label": "Second button",
29
+ "type": "link"
30
+ }
31
+ },
32
+ "category": "Calls to action",
33
+ "example": {
34
+ "text": "We’d love to meet you. Tell us you’re coming and we’ll save you a seat.",
35
+ "primary": {
36
+ "label": "Plan a visit",
37
+ "href": "/visit"
38
+ }
39
+ }
40
+ },
41
+ "example": {
42
+ "variant": "accent",
43
+ "data": {
44
+ "text": "We’d love to meet you. Tell us you’re coming and we’ll save you a seat.",
45
+ "primary": {
46
+ "label": "Plan a visit",
47
+ "href": "/visit"
48
+ }
49
+ }
50
+ }
51
+ }