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

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 (289) hide show
  1. package/CHANGELOG.md +105 -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/preview.mjs +8 -4
  9. package/cli/sections.mjs +188 -0
  10. package/cli/themes.mjs +52 -8
  11. package/docs/mobile.md +527 -0
  12. package/docs/sections.md +203 -0
  13. package/docs/theme-authoring.md +19 -3
  14. package/integration.mjs +76 -5
  15. package/mobile-template/client/AGENTS.md +97 -0
  16. package/mobile-template/client/README.md +65 -0
  17. package/mobile-template/client/app/_layout.tsx +56 -0
  18. package/mobile-template/client/app/index.tsx +21 -0
  19. package/mobile-template/client/app.json +21 -0
  20. package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
  21. package/mobile-template/client/eas.json +21 -0
  22. package/mobile-template/client/env.example +3 -0
  23. package/mobile-template/client/gitignore +42 -0
  24. package/mobile-template/client/package.json +38 -0
  25. package/mobile-template/client/sections/index.ts +11 -0
  26. package/mobile-template/client/tsconfig.json +7 -0
  27. package/mobile-template/editor/AGENTS.md +66 -0
  28. package/mobile-template/editor/README.md +73 -0
  29. package/mobile-template/editor/app/_layout.tsx +76 -0
  30. package/mobile-template/editor/app/auth.tsx +13 -0
  31. package/mobile-template/editor/app/index.tsx +55 -0
  32. package/mobile-template/editor/app.json +21 -0
  33. package/mobile-template/editor/components/Screen.tsx +51 -0
  34. package/mobile-template/editor/components/SignIn.tsx +36 -0
  35. package/mobile-template/editor/components/TextTabs.tsx +27 -0
  36. package/mobile-template/editor/eas.json +21 -0
  37. package/mobile-template/editor/env.example +5 -0
  38. package/mobile-template/editor/gitignore +42 -0
  39. package/mobile-template/editor/lib/appearance.tsx +12 -0
  40. package/mobile-template/editor/lib/config.ts +17 -0
  41. package/mobile-template/editor/lib/format.ts +24 -0
  42. package/mobile-template/editor/lib/tools.ts +16 -0
  43. package/mobile-template/editor/package.json +40 -0
  44. package/mobile-template/editor/tools/README.md +2 -0
  45. package/mobile-template/editor/tsconfig.json +7 -0
  46. package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
  47. package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
  48. package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
  49. package/mobile-template/recipes/booking/recipe.json +17 -0
  50. package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
  51. package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
  52. package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
  53. package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
  54. package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
  55. package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
  56. package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
  57. package/mobile-template/recipes/editor-content/recipe.json +8 -0
  58. package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
  59. package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
  60. package/mobile-template/recipes/editor-media/recipe.json +8 -0
  61. package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
  62. package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
  63. package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
  64. package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
  65. package/mobile-template/recipes/editor-sections/recipe.json +8 -0
  66. package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
  67. package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
  68. package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
  69. package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
  70. package/mobile-template/recipes/editor-translations/recipe.json +8 -0
  71. package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
  72. package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
  73. package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
  74. package/mobile-template/recipes/editor-writer/recipe.json +8 -0
  75. package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
  76. package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
  77. package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
  78. package/mobile-template/recipes/events/recipe.json +15 -0
  79. package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
  80. package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
  81. package/mobile-template/recipes/news/recipe.json +13 -0
  82. package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
  83. package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
  84. package/mobile-template/recipes/push/files/lib/push.ts +15 -0
  85. package/mobile-template/recipes/push/recipe.json +20 -0
  86. package/package.json +17 -13
  87. package/section-library/README.md +16 -0
  88. package/section-library/contact/Contact.astro +49 -0
  89. package/section-library/contact/section.json +106 -0
  90. package/section-library/cta-band/CtaBand.astro +28 -0
  91. package/section-library/cta-band/section.json +51 -0
  92. package/section-library/feature-grid/FeatureGrid.astro +54 -0
  93. package/section-library/feature-grid/section.json +107 -0
  94. package/section-library/gallery/Gallery.astro +29 -0
  95. package/section-library/gallery/section.json +51 -0
  96. package/section-library/hero-banner/HeroBanner.astro +42 -0
  97. package/section-library/hero-banner/section.json +81 -0
  98. package/section-library/latest-posts/LatestPosts.astro +22 -0
  99. package/section-library/latest-posts/section.json +37 -0
  100. package/section-library/pricing/Pricing.astro +40 -0
  101. package/section-library/pricing/section.json +117 -0
  102. package/section-library/questions/Questions.astro +38 -0
  103. package/section-library/questions/section.json +69 -0
  104. package/section-library/service-times/ServiceTimes.astro +42 -0
  105. package/section-library/service-times/section.json +127 -0
  106. package/section-library/team/Team.astro +45 -0
  107. package/section-library/team/section.json +88 -0
  108. package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
  109. package/section-library/upcoming-events/section.json +51 -0
  110. package/src/actions/index.ts +205 -174
  111. package/src/components/AiSettings.tsx +45 -5
  112. package/src/components/DesignManager.tsx +112 -16
  113. package/src/components/MobileApp.tsx +392 -0
  114. package/src/components/ThemeSections.astro +40 -21
  115. package/src/components/ThemeSectionsEditor.tsx +757 -229
  116. package/src/components/booking/BookingFlow.astro +6 -3
  117. package/src/components/calendar/BookingsHead.astro +51 -0
  118. package/src/components/calendar/CalendarBoard.tsx +618 -0
  119. package/src/components/calendar/MiniMonth.tsx +54 -0
  120. package/src/components/calendar/NewEventDialog.tsx +44 -0
  121. package/src/components/calendar/QuickAdd.tsx +263 -0
  122. package/src/components/calendar/RoomHolds.tsx +203 -0
  123. package/src/components/calendar/WhereItLands.tsx +51 -0
  124. package/src/components/editor/EventPanel.tsx +23 -2
  125. package/src/components/editor/PostEditor.tsx +206 -14
  126. package/src/components/import/ImportExport.tsx +4 -1
  127. package/src/components/import/runner.ts +1 -0
  128. package/src/components/sections/SectionFields.tsx +560 -0
  129. package/src/components/sections/SectionPicker.tsx +120 -0
  130. package/src/components/sections/TranslationPanel.tsx +143 -0
  131. package/src/components/sections/checks.ts +64 -0
  132. package/src/layouts/Admin.astro +11 -7
  133. package/src/layouts/Site.astro +136 -21
  134. package/src/lib/agent-discovery.ts +11 -7
  135. package/src/lib/agent.ts +35 -2
  136. package/src/lib/ai-schema.ts +5 -0
  137. package/src/lib/ai.ts +5 -0
  138. package/src/lib/api.ts +12 -0
  139. package/src/lib/backup/content.ts +6 -1
  140. package/src/lib/booking/agent.ts +3 -2
  141. package/src/lib/booking/approval.ts +70 -0
  142. package/src/lib/booking/holds.ts +120 -0
  143. package/src/lib/booking/service.ts +37 -3
  144. package/src/lib/calendar/agent-schema.ts +9 -1
  145. package/src/lib/calendar/agent.ts +16 -1
  146. package/src/lib/calendar/board.ts +171 -0
  147. package/src/lib/calendar/config.ts +3 -0
  148. package/src/lib/calendar/grid.ts +169 -0
  149. package/src/lib/calendar/migration.ts +16 -0
  150. package/src/lib/calendar/render.ts +9 -7
  151. package/src/lib/calendar/schedule.ts +166 -0
  152. package/src/lib/content-service.ts +203 -0
  153. package/src/lib/design-service.ts +153 -5
  154. package/src/lib/drafts.ts +3 -3
  155. package/src/lib/editor/auth.ts +264 -0
  156. package/src/lib/editor/bookings.ts +60 -0
  157. package/src/lib/editor/config.ts +25 -0
  158. package/src/lib/editor/documents.ts +227 -0
  159. package/src/lib/editor/http.ts +48 -0
  160. package/src/lib/editor/me.ts +30 -0
  161. package/src/lib/editor/migration.ts +57 -0
  162. package/src/lib/editor/next.ts +11 -0
  163. package/src/lib/editor/openapi.ts +124 -0
  164. package/src/lib/editor/schemas.ts +91 -0
  165. package/src/lib/editor/sections.ts +96 -0
  166. package/src/lib/editor/store.ts +184 -0
  167. package/src/lib/editor/token-route.ts +74 -0
  168. package/src/lib/editor/translations.ts +163 -0
  169. package/src/lib/email/accounts.ts +2 -1
  170. package/src/lib/email/types.ts +2 -1
  171. package/src/lib/home-source.ts +34 -0
  172. package/src/lib/i18n.ts +34 -0
  173. package/src/lib/import/apply.ts +31 -2
  174. package/src/lib/import/bundle.ts +4 -0
  175. package/src/lib/import/export.ts +1 -0
  176. package/src/lib/import/ghost.ts +1 -0
  177. package/src/lib/media/upload.ts +83 -0
  178. package/src/lib/mobile/associations.ts +25 -0
  179. package/src/lib/mobile/calendar.ts +26 -14
  180. package/src/lib/mobile/config.mjs +59 -0
  181. package/src/lib/mobile/content.ts +18 -13
  182. package/src/lib/mobile/embed.ts +83 -0
  183. package/src/lib/mobile/http.ts +14 -6
  184. package/src/lib/mobile/locale.ts +18 -0
  185. package/src/lib/mobile/openapi.ts +111 -0
  186. package/src/lib/mobile/pages.ts +71 -0
  187. package/src/lib/mobile/route.ts +3 -3
  188. package/src/lib/mobile/schemas.ts +60 -0
  189. package/src/lib/mobile/site.ts +41 -0
  190. package/src/lib/push/api.ts +158 -0
  191. package/src/lib/push/config.ts +20 -0
  192. package/src/lib/push/expo.ts +76 -0
  193. package/src/lib/push/migration.ts +60 -0
  194. package/src/lib/push/service.ts +302 -0
  195. package/src/lib/push/store.ts +144 -0
  196. package/src/lib/push/types.ts +43 -0
  197. package/src/lib/repo/migrations.ts +14 -0
  198. package/src/lib/repo/sql.ts +65 -20
  199. package/src/lib/repo/types.ts +10 -4
  200. package/src/lib/roles.ts +5 -0
  201. package/src/lib/scheduler.ts +6 -1
  202. package/src/lib/schema.ts +76 -1
  203. package/src/lib/section-ai.ts +55 -0
  204. package/src/lib/section-targets.ts +62 -0
  205. package/src/lib/sections-agent.ts +146 -0
  206. package/src/lib/sections.ts +242 -0
  207. package/src/lib/site-chrome.ts +34 -0
  208. package/src/lib/theme-assets.ts +16 -0
  209. package/src/lib/theme.ts +33 -4
  210. package/src/lib/translate.ts +97 -0
  211. package/src/lib/translations.ts +39 -0
  212. package/src/middleware.ts +38 -2
  213. package/src/pages/.well-known/apple-app-site-association.ts +6 -0
  214. package/src/pages/.well-known/assetlinks.json.ts +6 -0
  215. package/src/pages/[...slug].astro +8 -3
  216. package/src/pages/admin/app-auth.astro +159 -0
  217. package/src/pages/admin/c/[collection]/[id].astro +9 -0
  218. package/src/pages/admin/calendar/bookings.astro +32 -26
  219. package/src/pages/admin/calendar/calendars.astro +4 -263
  220. package/src/pages/admin/calendar/events/[id].astro +9 -0
  221. package/src/pages/admin/calendar/events/index.astro +41 -83
  222. package/src/pages/admin/calendar/index.astro +43 -0
  223. package/src/pages/admin/calendar/resources/[id].astro +27 -5
  224. package/src/pages/admin/calendar/resources/index.astro +16 -17
  225. package/src/pages/admin/calendar/resources/link.astro +52 -12
  226. package/src/pages/admin/calendar/settings.astro +458 -0
  227. package/src/pages/admin/integrations.astro +5 -11
  228. package/src/pages/admin/login.astro +6 -2
  229. package/src/pages/admin/logout.ts +5 -2
  230. package/src/pages/admin/mobile.astro +18 -0
  231. package/src/pages/admin/pages/[id].astro +62 -0
  232. package/src/pages/admin/section-preview.astro +61 -0
  233. package/src/pages/admin/users.astro +107 -11
  234. package/src/pages/api/agent/docs.ts +6 -2
  235. package/src/pages/api/agent/index.ts +1 -1
  236. package/src/pages/api/ai/settings.ts +7 -4
  237. package/src/pages/api/design/ai.ts +15 -0
  238. package/src/pages/api/design/index.ts +12 -42
  239. package/src/pages/api/design/translate.ts +15 -0
  240. package/src/pages/api/live/thumb.ts +1 -1
  241. package/src/pages/api/mcp.ts +2 -2
  242. package/src/pages/api/media/upload.ts +10 -78
  243. package/src/pages/api/push/admin.ts +27 -0
  244. package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
  245. package/src/pages/api/v1/admin/bookings/index.ts +6 -0
  246. package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
  247. package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
  248. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
  249. package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
  250. package/src/pages/api/v1/admin/documents/index.ts +13 -0
  251. package/src/pages/api/v1/admin/me.ts +6 -0
  252. package/src/pages/api/v1/admin/media/index.ts +32 -0
  253. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
  254. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
  255. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
  256. package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
  257. package/src/pages/api/v1/admin/sections/index.ts +6 -0
  258. package/src/pages/api/v1/auth/revoke.ts +5 -0
  259. package/src/pages/api/v1/auth/token.ts +5 -0
  260. package/src/pages/api/v1/devices/[token].ts +20 -0
  261. package/src/pages/api/v1/devices/index.ts +17 -0
  262. package/src/pages/api/v1/openapi.json.ts +7 -0
  263. package/src/pages/api/v1/pages/[...slug].ts +7 -0
  264. package/src/pages/api/v1/pages/home.ts +7 -0
  265. package/src/pages/api/v1/push/topics.ts +7 -0
  266. package/src/pages/api/v1/site.ts +7 -0
  267. package/src/pages/api/v1/theme.ts +7 -0
  268. package/src/pages/blog/[slug].astro +8 -2
  269. package/src/pages/blog/index.astro +11 -4
  270. package/src/pages/book/[resource].ics.ts +7 -1
  271. package/src/pages/embed/section/[...path].astro +42 -0
  272. package/src/pages/events/[slug].astro +7 -2
  273. package/src/pages/index.astro +11 -5
  274. package/src/pages/sitemap.xml.ts +8 -0
  275. package/src/styles/admin.css +117 -0
  276. package/src/styles/cards.css +5 -1
  277. package/src/styles/editor.css +57 -0
  278. package/src/styles/mobile-app.css +151 -0
  279. package/src/styles/schedule.css +325 -0
  280. package/src/styles/sections.css +21 -12
  281. package/src/styles/theme-editor.css +255 -0
  282. package/src/theme-sdk/contract.d.mts +2193 -89
  283. package/src/theme-sdk/contract.mjs +77 -20
  284. package/src/theme-sdk/fields.d.mts +88 -0
  285. package/src/theme-sdk/fields.mjs +330 -0
  286. package/src/theme-sdk/index.ts +14 -2
  287. package/src/theme-sdk/tokens.d.mts +11 -0
  288. package/src/virtual.d.ts +4 -0
  289. package/src/pages/admin/calendar/index.ts +0 -4
@@ -0,0 +1,61 @@
1
+ import { useState } from 'react';
2
+ import { Text, View } from 'react-native';
3
+ import { Stack, router } from 'expo-router';
4
+ import type { DocKind } from '@acms-subir/client';
5
+ import { EditorButton, EditorInput, Notice, OptionGrid, Segmented, errorMessage, useCreateDocument, useEditorTheme, useMe } from '@acms-subir/native/editor';
6
+ import { Screen } from '../../components/Screen';
7
+
8
+ export default function NewDocumentScreen() {
9
+ const t = useEditorTheme();
10
+ const me = useMe();
11
+ const create = useCreateDocument();
12
+ const [kind, setKind] = useState<DocKind>('post');
13
+ const [title, setTitle] = useState('');
14
+ const collections = me.data?.features.collections ?? [];
15
+ const [collection, setCollection] = useState('');
16
+ const [date, setDate] = useState('');
17
+ const [time, setTime] = useState('');
18
+ const validDate = /^\d{4}-\d{2}-\d{2}$/.test(date), validTime = !time || /^([01]?\d|2[0-3]):[0-5]\d$/.test(time);
19
+ const ready = title.trim() && (kind !== 'event' || (validDate && validTime));
20
+ const submit = async () => {
21
+ const input = kind === 'post' ? { kind, title: title.trim(), ...(collection ? { collection } : {}) }
22
+ : kind === 'page' ? { kind, title: title.trim() }
23
+ : { kind, title: title.trim(), start: time ? `${date}T${time.padStart(5, '0')}` : date, allDay: !time };
24
+ const doc = await create.mutateAsync(input as never).catch(() => null);
25
+ if (doc) router.replace({ pathname: '/write/[kind]/[id]', params: { kind: doc.kind, id: doc.id } });
26
+ };
27
+ return (
28
+ <Screen maxWidth={640}>
29
+ <Stack.Screen options={{ title: 'New' }} />
30
+ <Segmented<DocKind> accessibilityLabel="What to write" value={kind} onChange={setKind} options={[
31
+ { value: 'post', label: collections.length === 1 ? collections[0].singular : 'Post' }, { value: 'page', label: 'Page' },
32
+ ...(me.data?.features.calendar ? [{ value: 'event' as const, label: 'Event' }] : []),
33
+ ]} />
34
+ <View style={{ gap: 6 }}>
35
+ <Text style={t.text.label}>Title</Text>
36
+ <EditorInput autoFocus value={title} onChangeText={setTitle} placeholder="A clear, short title" accessibilityLabel="Title" returnKeyType="next" />
37
+ </View>
38
+ {kind === 'post' && collections.length > 1 && (
39
+ <View style={{ gap: 6 }}>
40
+ <Text style={t.text.label}>Collection</Text>
41
+ <OptionGrid value={collection || collections[0].key} onChange={setCollection} options={collections.map((c) => ({ value: c.key, label: c.singular }))} />
42
+ </View>
43
+ )}
44
+ {kind === 'event' && (
45
+ <View style={{ flexDirection: 'row', gap: 10 }}>
46
+ <View style={{ flex: 1, gap: 6 }}>
47
+ <Text style={t.text.label}>Date</Text>
48
+ <EditorInput value={date} onChangeText={setDate} placeholder="YYYY-MM-DD" keyboardType="numbers-and-punctuation" invalid={!!date && !validDate} accessibilityLabel="Date" />
49
+ </View>
50
+ <View style={{ flex: 1, gap: 6 }}>
51
+ <Text style={t.text.label}>Start time</Text>
52
+ <EditorInput value={time} onChangeText={setTime} placeholder="19:30 (empty = all day)" keyboardType="numbers-and-punctuation" invalid={!validTime} accessibilityLabel="Start time" />
53
+ </View>
54
+ </View>
55
+ )}
56
+ {create.error && <Notice tone="error">{errorMessage(create.error)}</Notice>}
57
+ <EditorButton variant="primary" label="Create draft" busy={create.isPending} disabled={!ready || create.isPending} onPress={() => { void submit(); }} />
58
+ <Text style={t.text.meta}>It starts as a draft. Nothing is public until you publish.</Text>
59
+ </Screen>
60
+ );
61
+ }
@@ -0,0 +1 @@
1
+ export default { title: 'Write', description: 'Start a post, page or event', href: '/write/new', order: 20 };
@@ -0,0 +1,8 @@
1
+ {
2
+ "kind": "editor",
3
+ "title": "Writer",
4
+ "description": "Start a post, page or event; edit title, summary, body (bold, italic, links, lists), tags and address; save drafts, publish or schedule; a clear “Keep mine / Use theirs” when someone saved first.",
5
+ "packages": [],
6
+ "routes": ["/write/new", "/write/[kind]/[id]"],
7
+ "next": ["It appears on Home (tools/writer.ts). With editor-content, the detail screen gets an Edit button.", "Bodies with images, embeds or cards from the website editor open read-only here (edit those on the website)."]
8
+ }
@@ -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.4",
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",
@@ -59,15 +61,17 @@
59
61
  "@dnd-kit/sortable": "^10.0.0",
60
62
  "@dnd-kit/utilities": "^3.2.2",
61
63
  "@libsql/client": "^0.15.0",
62
- "@tiptap/core": "^3.30.1",
63
- "@tiptap/extension-code-block-lowlight": "^3.30.1",
64
+ "@tiptap/core": "3.30.1",
65
+ "@tiptap/extension-bubble-menu": "3.30.1",
66
+ "@tiptap/extension-code-block-lowlight": "3.30.1",
67
+ "@tiptap/extension-floating-menu": "3.30.1",
64
68
  "@tiptap/extension-highlight": "3.30.1",
65
- "@tiptap/extension-table": "^3.30.1",
66
- "@tiptap/extensions": "^3.30.1",
67
- "@tiptap/pm": "^3.30.1",
68
- "@tiptap/react": "^3.30.1",
69
- "@tiptap/starter-kit": "^3.30.1",
70
- "@tiptap/suggestion": "^3.30.1",
69
+ "@tiptap/extension-table": "3.30.1",
70
+ "@tiptap/extensions": "3.30.1",
71
+ "@tiptap/pm": "3.30.1",
72
+ "@tiptap/react": "3.30.1",
73
+ "@tiptap/starter-kit": "3.30.1",
74
+ "@tiptap/suggestion": "3.30.1",
71
75
  "franc-min": "^6.2.0",
72
76
  "hast-util-to-html": "^9.0.5",
73
77
  "lowlight": "^3.3.0",
@@ -76,12 +80,12 @@
76
80
  "pdfjs-dist": "^6.3.289",
77
81
  "prismjs": "^1.30.0",
78
82
  "sanitize-html": "^2.17.7",
83
+ "semver": "^7.7.2",
79
84
  "simple-icons": "^16.27.1",
85
+ "tar": "^7.5.1",
80
86
  "turndown": "^7.2.4",
81
87
  "zod": "^3.24.0",
82
- "zod-to-json-schema": "^3.25.2",
83
- "semver": "^7.7.2",
84
- "tar": "^7.5.1"
88
+ "zod-to-json-schema": "^3.25.2"
85
89
  },
86
90
  "peerDependencies": {
87
91
  "@astrojs/cloudflare": "^12.6.13",
@@ -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>