@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.
- package/CHANGELOG.md +105 -0
- package/cli/common.mjs +2 -2
- package/cli/legacy.mjs +1 -1
- package/cli/main.mjs +82 -8
- package/cli/mobile-doctor.mjs +430 -0
- package/cli/mobile.mjs +315 -0
- package/cli/native-sections.mjs +168 -0
- package/cli/preview.mjs +8 -4
- package/cli/sections.mjs +188 -0
- package/cli/themes.mjs +52 -8
- package/docs/mobile.md +527 -0
- package/docs/sections.md +203 -0
- package/docs/theme-authoring.md +19 -3
- package/integration.mjs +76 -5
- package/mobile-template/client/AGENTS.md +97 -0
- package/mobile-template/client/README.md +65 -0
- package/mobile-template/client/app/_layout.tsx +56 -0
- package/mobile-template/client/app/index.tsx +21 -0
- package/mobile-template/client/app.json +21 -0
- package/mobile-template/client/components/LanguageSwitch.tsx +31 -0
- package/mobile-template/client/eas.json +21 -0
- package/mobile-template/client/env.example +3 -0
- package/mobile-template/client/gitignore +42 -0
- package/mobile-template/client/package.json +38 -0
- package/mobile-template/client/sections/index.ts +11 -0
- package/mobile-template/client/tsconfig.json +7 -0
- package/mobile-template/editor/AGENTS.md +66 -0
- package/mobile-template/editor/README.md +73 -0
- package/mobile-template/editor/app/_layout.tsx +76 -0
- package/mobile-template/editor/app/auth.tsx +13 -0
- package/mobile-template/editor/app/index.tsx +55 -0
- package/mobile-template/editor/app.json +21 -0
- package/mobile-template/editor/components/Screen.tsx +51 -0
- package/mobile-template/editor/components/SignIn.tsx +36 -0
- package/mobile-template/editor/components/TextTabs.tsx +27 -0
- package/mobile-template/editor/eas.json +21 -0
- package/mobile-template/editor/env.example +5 -0
- package/mobile-template/editor/gitignore +42 -0
- package/mobile-template/editor/lib/appearance.tsx +12 -0
- package/mobile-template/editor/lib/config.ts +17 -0
- package/mobile-template/editor/lib/format.ts +24 -0
- package/mobile-template/editor/lib/tools.ts +16 -0
- package/mobile-template/editor/package.json +40 -0
- package/mobile-template/editor/tools/README.md +2 -0
- package/mobile-template/editor/tsconfig.json +7 -0
- package/mobile-template/recipes/booking/files/app/book/[resource].tsx +57 -0
- package/mobile-template/recipes/booking/files/app/book/details.tsx +105 -0
- package/mobile-template/recipes/booking/files/app/book/index.tsx +38 -0
- package/mobile-template/recipes/booking/recipe.json +17 -0
- package/mobile-template/recipes/editor-bookings/files/app/bookings/index.tsx +113 -0
- package/mobile-template/recipes/editor-bookings/files/tools/bookings.ts +1 -0
- package/mobile-template/recipes/editor-bookings/recipe.json +8 -0
- package/mobile-template/recipes/editor-content/files/app/content/[kind]/[id].tsx +13 -0
- package/mobile-template/recipes/editor-content/files/app/content/index.tsx +76 -0
- package/mobile-template/recipes/editor-content/files/components/DocumentDetail.tsx +55 -0
- package/mobile-template/recipes/editor-content/files/tools/content.ts +1 -0
- package/mobile-template/recipes/editor-content/recipe.json +8 -0
- package/mobile-template/recipes/editor-media/files/app/media/index.tsx +73 -0
- package/mobile-template/recipes/editor-media/files/tools/media.ts +1 -0
- package/mobile-template/recipes/editor-media/recipe.json +8 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/[pageId]/[sectionId].tsx +12 -0
- package/mobile-template/recipes/editor-sections/files/app/sections/index.tsx +48 -0
- package/mobile-template/recipes/editor-sections/files/components/SectionEditorPane.tsx +77 -0
- package/mobile-template/recipes/editor-sections/files/tools/sections.ts +1 -0
- package/mobile-template/recipes/editor-sections/recipe.json +8 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/index.tsx +63 -0
- package/mobile-template/recipes/editor-translations/files/app/translations/review.tsx +14 -0
- package/mobile-template/recipes/editor-translations/files/components/ReviewPane.tsx +72 -0
- package/mobile-template/recipes/editor-translations/files/tools/translations.ts +1 -0
- package/mobile-template/recipes/editor-translations/recipe.json +8 -0
- package/mobile-template/recipes/editor-writer/files/app/write/[kind]/[id].tsx +132 -0
- package/mobile-template/recipes/editor-writer/files/app/write/new.tsx +61 -0
- package/mobile-template/recipes/editor-writer/files/tools/writer.ts +1 -0
- package/mobile-template/recipes/editor-writer/recipe.json +8 -0
- package/mobile-template/recipes/events/files/app/events/[slug].tsx +41 -0
- package/mobile-template/recipes/events/files/app/events/index.tsx +50 -0
- package/mobile-template/recipes/events/files/lib/addToCalendar.ts +27 -0
- package/mobile-template/recipes/events/recipe.json +15 -0
- package/mobile-template/recipes/news/files/app/news/[slug].tsx +25 -0
- package/mobile-template/recipes/news/files/app/news/index.tsx +50 -0
- package/mobile-template/recipes/news/recipe.json +13 -0
- package/mobile-template/recipes/push/files/app/notifications.tsx +64 -0
- package/mobile-template/recipes/push/files/components/PushSetup.tsx +15 -0
- package/mobile-template/recipes/push/files/lib/push.ts +15 -0
- package/mobile-template/recipes/push/recipe.json +20 -0
- package/package.json +17 -13
- package/section-library/README.md +16 -0
- package/section-library/contact/Contact.astro +49 -0
- package/section-library/contact/section.json +106 -0
- package/section-library/cta-band/CtaBand.astro +28 -0
- package/section-library/cta-band/section.json +51 -0
- package/section-library/feature-grid/FeatureGrid.astro +54 -0
- package/section-library/feature-grid/section.json +107 -0
- package/section-library/gallery/Gallery.astro +29 -0
- package/section-library/gallery/section.json +51 -0
- package/section-library/hero-banner/HeroBanner.astro +42 -0
- package/section-library/hero-banner/section.json +81 -0
- package/section-library/latest-posts/LatestPosts.astro +22 -0
- package/section-library/latest-posts/section.json +37 -0
- package/section-library/pricing/Pricing.astro +40 -0
- package/section-library/pricing/section.json +117 -0
- package/section-library/questions/Questions.astro +38 -0
- package/section-library/questions/section.json +69 -0
- package/section-library/service-times/ServiceTimes.astro +42 -0
- package/section-library/service-times/section.json +127 -0
- package/section-library/team/Team.astro +45 -0
- package/section-library/team/section.json +88 -0
- package/section-library/upcoming-events/UpcomingEvents.astro +54 -0
- package/section-library/upcoming-events/section.json +51 -0
- package/src/actions/index.ts +205 -174
- package/src/components/AiSettings.tsx +45 -5
- package/src/components/DesignManager.tsx +112 -16
- package/src/components/MobileApp.tsx +392 -0
- package/src/components/ThemeSections.astro +40 -21
- package/src/components/ThemeSectionsEditor.tsx +757 -229
- package/src/components/booking/BookingFlow.astro +6 -3
- package/src/components/calendar/BookingsHead.astro +51 -0
- package/src/components/calendar/CalendarBoard.tsx +618 -0
- package/src/components/calendar/MiniMonth.tsx +54 -0
- package/src/components/calendar/NewEventDialog.tsx +44 -0
- package/src/components/calendar/QuickAdd.tsx +263 -0
- package/src/components/calendar/RoomHolds.tsx +203 -0
- package/src/components/calendar/WhereItLands.tsx +51 -0
- package/src/components/editor/EventPanel.tsx +23 -2
- package/src/components/editor/PostEditor.tsx +206 -14
- package/src/components/import/ImportExport.tsx +4 -1
- package/src/components/import/runner.ts +1 -0
- package/src/components/sections/SectionFields.tsx +560 -0
- package/src/components/sections/SectionPicker.tsx +120 -0
- package/src/components/sections/TranslationPanel.tsx +143 -0
- package/src/components/sections/checks.ts +64 -0
- package/src/layouts/Admin.astro +11 -7
- package/src/layouts/Site.astro +136 -21
- package/src/lib/agent-discovery.ts +11 -7
- package/src/lib/agent.ts +35 -2
- package/src/lib/ai-schema.ts +5 -0
- package/src/lib/ai.ts +5 -0
- package/src/lib/api.ts +12 -0
- package/src/lib/backup/content.ts +6 -1
- package/src/lib/booking/agent.ts +3 -2
- package/src/lib/booking/approval.ts +70 -0
- package/src/lib/booking/holds.ts +120 -0
- package/src/lib/booking/service.ts +37 -3
- package/src/lib/calendar/agent-schema.ts +9 -1
- package/src/lib/calendar/agent.ts +16 -1
- package/src/lib/calendar/board.ts +171 -0
- package/src/lib/calendar/config.ts +3 -0
- package/src/lib/calendar/grid.ts +169 -0
- package/src/lib/calendar/migration.ts +16 -0
- package/src/lib/calendar/render.ts +9 -7
- package/src/lib/calendar/schedule.ts +166 -0
- package/src/lib/content-service.ts +203 -0
- package/src/lib/design-service.ts +153 -5
- package/src/lib/drafts.ts +3 -3
- package/src/lib/editor/auth.ts +264 -0
- package/src/lib/editor/bookings.ts +60 -0
- package/src/lib/editor/config.ts +25 -0
- package/src/lib/editor/documents.ts +227 -0
- package/src/lib/editor/http.ts +48 -0
- package/src/lib/editor/me.ts +30 -0
- package/src/lib/editor/migration.ts +57 -0
- package/src/lib/editor/next.ts +11 -0
- package/src/lib/editor/openapi.ts +124 -0
- package/src/lib/editor/schemas.ts +91 -0
- package/src/lib/editor/sections.ts +96 -0
- package/src/lib/editor/store.ts +184 -0
- package/src/lib/editor/token-route.ts +74 -0
- package/src/lib/editor/translations.ts +163 -0
- package/src/lib/email/accounts.ts +2 -1
- package/src/lib/email/types.ts +2 -1
- package/src/lib/home-source.ts +34 -0
- package/src/lib/i18n.ts +34 -0
- package/src/lib/import/apply.ts +31 -2
- package/src/lib/import/bundle.ts +4 -0
- package/src/lib/import/export.ts +1 -0
- package/src/lib/import/ghost.ts +1 -0
- package/src/lib/media/upload.ts +83 -0
- package/src/lib/mobile/associations.ts +25 -0
- package/src/lib/mobile/calendar.ts +26 -14
- package/src/lib/mobile/config.mjs +59 -0
- package/src/lib/mobile/content.ts +18 -13
- package/src/lib/mobile/embed.ts +83 -0
- package/src/lib/mobile/http.ts +14 -6
- package/src/lib/mobile/locale.ts +18 -0
- package/src/lib/mobile/openapi.ts +111 -0
- package/src/lib/mobile/pages.ts +71 -0
- package/src/lib/mobile/route.ts +3 -3
- package/src/lib/mobile/schemas.ts +60 -0
- package/src/lib/mobile/site.ts +41 -0
- package/src/lib/push/api.ts +158 -0
- package/src/lib/push/config.ts +20 -0
- package/src/lib/push/expo.ts +76 -0
- package/src/lib/push/migration.ts +60 -0
- package/src/lib/push/service.ts +302 -0
- package/src/lib/push/store.ts +144 -0
- package/src/lib/push/types.ts +43 -0
- package/src/lib/repo/migrations.ts +14 -0
- package/src/lib/repo/sql.ts +65 -20
- package/src/lib/repo/types.ts +10 -4
- package/src/lib/roles.ts +5 -0
- package/src/lib/scheduler.ts +6 -1
- package/src/lib/schema.ts +76 -1
- package/src/lib/section-ai.ts +55 -0
- package/src/lib/section-targets.ts +62 -0
- package/src/lib/sections-agent.ts +146 -0
- package/src/lib/sections.ts +242 -0
- package/src/lib/site-chrome.ts +34 -0
- package/src/lib/theme-assets.ts +16 -0
- package/src/lib/theme.ts +33 -4
- package/src/lib/translate.ts +97 -0
- package/src/lib/translations.ts +39 -0
- package/src/middleware.ts +38 -2
- package/src/pages/.well-known/apple-app-site-association.ts +6 -0
- package/src/pages/.well-known/assetlinks.json.ts +6 -0
- package/src/pages/[...slug].astro +8 -3
- package/src/pages/admin/app-auth.astro +159 -0
- package/src/pages/admin/c/[collection]/[id].astro +9 -0
- package/src/pages/admin/calendar/bookings.astro +32 -26
- package/src/pages/admin/calendar/calendars.astro +4 -263
- package/src/pages/admin/calendar/events/[id].astro +9 -0
- package/src/pages/admin/calendar/events/index.astro +41 -83
- package/src/pages/admin/calendar/index.astro +43 -0
- package/src/pages/admin/calendar/resources/[id].astro +27 -5
- package/src/pages/admin/calendar/resources/index.astro +16 -17
- package/src/pages/admin/calendar/resources/link.astro +52 -12
- package/src/pages/admin/calendar/settings.astro +458 -0
- package/src/pages/admin/integrations.astro +5 -11
- package/src/pages/admin/login.astro +6 -2
- package/src/pages/admin/logout.ts +5 -2
- package/src/pages/admin/mobile.astro +18 -0
- package/src/pages/admin/pages/[id].astro +62 -0
- package/src/pages/admin/section-preview.astro +61 -0
- package/src/pages/admin/users.astro +107 -11
- package/src/pages/api/agent/docs.ts +6 -2
- package/src/pages/api/agent/index.ts +1 -1
- package/src/pages/api/ai/settings.ts +7 -4
- package/src/pages/api/design/ai.ts +15 -0
- package/src/pages/api/design/index.ts +12 -42
- package/src/pages/api/design/translate.ts +15 -0
- package/src/pages/api/live/thumb.ts +1 -1
- package/src/pages/api/mcp.ts +2 -2
- package/src/pages/api/media/upload.ts +10 -78
- package/src/pages/api/push/admin.ts +27 -0
- package/src/pages/api/v1/admin/bookings/[id]/[action].ts +14 -0
- package/src/pages/api/v1/admin/bookings/index.ts +6 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/[action].ts +17 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/index.ts +20 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/[action].ts +26 -0
- package/src/pages/api/v1/admin/documents/[kind]/[id]/translations/[locale]/index.ts +18 -0
- package/src/pages/api/v1/admin/documents/index.ts +13 -0
- package/src/pages/api/v1/admin/me.ts +6 -0
- package/src/pages/api/v1/admin/media/index.ts +32 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/publish.ts +14 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/[action].ts +22 -0
- package/src/pages/api/v1/admin/sections/[pageId]/[sectionId]/translations/[locale]/index.ts +14 -0
- package/src/pages/api/v1/admin/sections/index.ts +6 -0
- package/src/pages/api/v1/auth/revoke.ts +5 -0
- package/src/pages/api/v1/auth/token.ts +5 -0
- package/src/pages/api/v1/devices/[token].ts +20 -0
- package/src/pages/api/v1/devices/index.ts +17 -0
- package/src/pages/api/v1/openapi.json.ts +7 -0
- package/src/pages/api/v1/pages/[...slug].ts +7 -0
- package/src/pages/api/v1/pages/home.ts +7 -0
- package/src/pages/api/v1/push/topics.ts +7 -0
- package/src/pages/api/v1/site.ts +7 -0
- package/src/pages/api/v1/theme.ts +7 -0
- package/src/pages/blog/[slug].astro +8 -2
- package/src/pages/blog/index.astro +11 -4
- package/src/pages/book/[resource].ics.ts +7 -1
- package/src/pages/embed/section/[...path].astro +42 -0
- package/src/pages/events/[slug].astro +7 -2
- package/src/pages/index.astro +11 -5
- package/src/pages/sitemap.xml.ts +8 -0
- package/src/styles/admin.css +117 -0
- package/src/styles/cards.css +5 -1
- package/src/styles/editor.css +57 -0
- package/src/styles/mobile-app.css +151 -0
- package/src/styles/schedule.css +325 -0
- package/src/styles/sections.css +21 -12
- package/src/styles/theme-editor.css +255 -0
- package/src/theme-sdk/contract.d.mts +2193 -89
- package/src/theme-sdk/contract.mjs +77 -20
- package/src/theme-sdk/fields.d.mts +88 -0
- package/src/theme-sdk/fields.mjs +330 -0
- package/src/theme-sdk/index.ts +14 -2
- package/src/theme-sdk/tokens.d.mts +11 -0
- package/src/virtual.d.ts +4 -0
- package/src/pages/admin/calendar/index.ts +0 -4
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
# Your ACMS site (public origin). Only public values belong here: EXPO_PUBLIC_*
|
|
2
|
+
# variables are bundled into the app. Never put passwords, API keys or tokens
|
|
3
|
+
# in it: staff sign in through the site in the browser, and the app keeps
|
|
4
|
+
# its per-device tokens in secure storage.
|
|
5
|
+
EXPO_PUBLIC_ACMS_URL=https://example.com
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
|
2
|
+
|
|
3
|
+
# dependencies
|
|
4
|
+
node_modules/
|
|
5
|
+
|
|
6
|
+
# Expo
|
|
7
|
+
.expo/
|
|
8
|
+
dist/
|
|
9
|
+
web-build/
|
|
10
|
+
expo-env.d.ts
|
|
11
|
+
|
|
12
|
+
# Native
|
|
13
|
+
.kotlin/
|
|
14
|
+
*.orig.*
|
|
15
|
+
*.jks
|
|
16
|
+
*.p8
|
|
17
|
+
*.p12
|
|
18
|
+
*.key
|
|
19
|
+
*.mobileprovision
|
|
20
|
+
|
|
21
|
+
# Metro
|
|
22
|
+
.metro-health-check*
|
|
23
|
+
|
|
24
|
+
# debug
|
|
25
|
+
npm-debug.*
|
|
26
|
+
yarn-debug.*
|
|
27
|
+
yarn-error.*
|
|
28
|
+
|
|
29
|
+
# macOS
|
|
30
|
+
.DS_Store
|
|
31
|
+
*.pem
|
|
32
|
+
|
|
33
|
+
# local env files
|
|
34
|
+
.env*.local
|
|
35
|
+
|
|
36
|
+
# typescript
|
|
37
|
+
*.tsbuildinfo
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
# generated native folders
|
|
41
|
+
/ios
|
|
42
|
+
/android
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createContext, useContext, useState, type ReactNode } from 'react';
|
|
2
|
+
|
|
3
|
+
// Light / Dark / Auto for this session (Auto follows the device). Persist it
|
|
4
|
+
// with expo-secure-store or AsyncStorage if people should keep their choice.
|
|
5
|
+
export type Appearance = 'light' | 'dark' | 'auto';
|
|
6
|
+
const Ctx = createContext<{ appearance: Appearance; setAppearance: (a: Appearance) => void }>({ appearance: 'auto', setAppearance: () => {} });
|
|
7
|
+
|
|
8
|
+
export function AppearanceProvider({ children }: { children: ReactNode }) {
|
|
9
|
+
const [appearance, setAppearance] = useState<Appearance>('auto');
|
|
10
|
+
return <Ctx.Provider value={{ appearance, setAppearance }}>{children}</Ctx.Provider>;
|
|
11
|
+
}
|
|
12
|
+
export const useAppearance = () => useContext(Ctx);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import Constants from 'expo-constants';
|
|
2
|
+
import { redirectUriFor } from '@acms-subir/native/editor';
|
|
3
|
+
|
|
4
|
+
// Set in .env (production site) or .env.local (written by `npx acms mobile dev`).
|
|
5
|
+
export const siteUrl = process.env.EXPO_PUBLIC_ACMS_URL;
|
|
6
|
+
|
|
7
|
+
// The app's scheme (app.json) doubles as its sign-in return address:
|
|
8
|
+
// iOS/Android: <scheme>://auth web: <this web app's origin>/auth
|
|
9
|
+
// Both must be listed in the site's astro.config.mjs:
|
|
10
|
+
// acms({ mobile: { editor: { redirectUris: ['<scheme>://auth'] } } })
|
|
11
|
+
const configured = Constants.expoConfig?.scheme;
|
|
12
|
+
export const scheme = (Array.isArray(configured) ? configured[0] : configured) || '__ACMS_APP_SCHEME__';
|
|
13
|
+
export const redirectUri = redirectUriFor(scheme);
|
|
14
|
+
|
|
15
|
+
// Shown on the site's approve screen and device list. The bundle id is a good,
|
|
16
|
+
// stable choice (letters, digits, . _ : -).
|
|
17
|
+
export const clientId = '__ACMS_BUNDLE_ID__';
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Dates for staff screens, in the device's language and time zone.
|
|
2
|
+
export function shortDate(iso: string | null | undefined, locale?: string): string {
|
|
3
|
+
if (!iso) return '';
|
|
4
|
+
const d = new Date(iso);
|
|
5
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
6
|
+
const sameYear = d.getFullYear() === new Date().getFullYear();
|
|
7
|
+
return d.toLocaleDateString(locale, { month: 'short', day: 'numeric', ...(sameYear ? {} : { year: 'numeric' }) });
|
|
8
|
+
}
|
|
9
|
+
export function dateTime(iso: string | null | undefined, locale?: string): string {
|
|
10
|
+
if (!iso) return '';
|
|
11
|
+
const d = new Date(iso);
|
|
12
|
+
if (Number.isNaN(d.getTime())) return '';
|
|
13
|
+
return d.toLocaleString(locale, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
14
|
+
}
|
|
15
|
+
/** "just now", "5 min ago", "3 h ago", else a short date */
|
|
16
|
+
export function ago(iso: string | null | undefined, now = Date.now()): string {
|
|
17
|
+
if (!iso) return '';
|
|
18
|
+
const ms = now - new Date(iso).getTime();
|
|
19
|
+
if (!Number.isFinite(ms)) return '';
|
|
20
|
+
if (ms < 60_000) return 'just now';
|
|
21
|
+
if (ms < 3_600_000) return `${Math.floor(ms / 60_000)} min ago`;
|
|
22
|
+
if (ms < 86_400_000) return `${Math.floor(ms / 3_600_000)} h ago`;
|
|
23
|
+
return shortDate(iso);
|
|
24
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// Screens the app offers on Home. Each recipe adds one file to tools/
|
|
2
|
+
// (e.g. tools/content.ts) exporting { title, description, href, order }, so
|
|
3
|
+
// Home lists it without you editing this file. Add your own the same way.
|
|
4
|
+
export interface Tool { title: string; description: string; href: string; order?: number }
|
|
5
|
+
|
|
6
|
+
// require.context is Metro's static folder import (enabled with expo-router)
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
8
|
+
// @ts-ignore
|
|
9
|
+
const context = require.context('../tools', false, /\.tsx?$/);
|
|
10
|
+
|
|
11
|
+
export const tools: (Tool & { name: string })[] = context.keys()
|
|
12
|
+
.map((key: string) => ({ name: key.replace(/^\.\/|\.tsx?$/g, ''), ...(context(key).default as Tool) }))
|
|
13
|
+
.filter((t: Tool) => t && typeof t.href === 'string')
|
|
14
|
+
.sort((a: Tool, b: Tool) => (a.order ?? 50) - (b.order ?? 50) || a.title.localeCompare(b.title));
|
|
15
|
+
|
|
16
|
+
export const hasTool = (name: string) => tools.some((t) => t.name === name);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__ACMS_APP_SLUG__",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "expo-router/entry",
|
|
6
|
+
"acms": { "app": "editor", "template": 1 },
|
|
7
|
+
"scripts": {
|
|
8
|
+
"start": "expo start",
|
|
9
|
+
"android": "expo start --android",
|
|
10
|
+
"ios": "expo start --ios",
|
|
11
|
+
"web": "expo start --web",
|
|
12
|
+
"typecheck": "tsc --noEmit",
|
|
13
|
+
"doctor": "npx expo-doctor"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@acms-subir/client": "^0.1.0",
|
|
17
|
+
"@acms-subir/native": "^0.1.0",
|
|
18
|
+
"@tanstack/react-query": "^5.104.0",
|
|
19
|
+
"expo": "~57.0.26",
|
|
20
|
+
"expo-constants": "~57.0.20",
|
|
21
|
+
"expo-crypto": "~57.0.3",
|
|
22
|
+
"expo-device": "~57.0.2",
|
|
23
|
+
"expo-linking": "~57.0.11",
|
|
24
|
+
"expo-router": "~57.0.24",
|
|
25
|
+
"expo-secure-store": "~57.0.4",
|
|
26
|
+
"expo-status-bar": "~57.0.1",
|
|
27
|
+
"expo-system-ui": "~57.0.4",
|
|
28
|
+
"expo-web-browser": "~57.0.3",
|
|
29
|
+
"react": "19.2.3",
|
|
30
|
+
"react-dom": "19.2.3",
|
|
31
|
+
"react-native": "0.86.3",
|
|
32
|
+
"react-native-safe-area-context": "~5.7.0",
|
|
33
|
+
"react-native-screens": "~4.26.0",
|
|
34
|
+
"react-native-web": "~0.21.0"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/react": "~19.2.2",
|
|
38
|
+
"typescript": "~6.0.3"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
4
|
+
import { ErrorView, useAvailability, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
|
|
5
|
+
|
|
6
|
+
// Open slots for one booking link over the next two weeks.
|
|
7
|
+
export default function SlotsScreen() {
|
|
8
|
+
const t = useTheme();
|
|
9
|
+
const { locale } = useLocale();
|
|
10
|
+
const { gutter } = useLayout();
|
|
11
|
+
const { resource, link } = useLocalSearchParams<{ resource: string; link: string }>();
|
|
12
|
+
const resources = useResources();
|
|
13
|
+
const r = resources.data?.resources.find((x) => x.slug === resource);
|
|
14
|
+
const l = r?.links.find((x) => x.slug === link);
|
|
15
|
+
const [minutes, setMinutes] = useState<number | undefined>();
|
|
16
|
+
const length = minutes ?? l?.lengths[0];
|
|
17
|
+
const slots = useAvailability(resource && link ? { resource, link, days: 14, minutes: length } : null);
|
|
18
|
+
if (slots.error && !slots.data) return <ErrorView error={slots.error} retry={() => slots.refetch()} />;
|
|
19
|
+
const tz = slots.data?.timezone;
|
|
20
|
+
const day = (date: string) => new Date(`${date}T12:00:00Z`).toLocaleDateString(locale, { weekday: 'long', month: 'short', day: 'numeric', timeZone: 'UTC' });
|
|
21
|
+
const time = (iso: string) => new Date(iso).toLocaleTimeString(locale, { hour: 'numeric', minute: '2-digit', timeZone: tz });
|
|
22
|
+
const days = (slots.data?.days ?? []).filter((d) => d.slots.length);
|
|
23
|
+
return (
|
|
24
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 20, width: '100%', maxWidth: 840, alignSelf: 'center' }}>
|
|
25
|
+
<Stack.Screen options={{ title: l?.name ?? 'Choose a time' }} />
|
|
26
|
+
{r && <Text style={t.styles.text.muted}>{r.name}{tz ? ` · times in ${tz}` : ''}</Text>}
|
|
27
|
+
{!!l && l.lengths.length > 1 && (
|
|
28
|
+
<View style={{ flexDirection: 'row', gap: 16 }} accessibilityRole="tablist">
|
|
29
|
+
{l.lengths.map((m) => (
|
|
30
|
+
<Pressable key={m} accessibilityRole="tab" accessibilityState={{ selected: m === length }} onPress={() => setMinutes(m)} hitSlop={8}>
|
|
31
|
+
<Text style={[t.styles.text.body, { color: m === length ? t.colors.ink : t.colors.muted, fontWeight: m === length ? '700' : '400' }]}>{m} min</Text>
|
|
32
|
+
</Pressable>
|
|
33
|
+
))}
|
|
34
|
+
</View>
|
|
35
|
+
)}
|
|
36
|
+
{slots.isPending && <ActivityIndicator color={t.colors.accent} />}
|
|
37
|
+
{!slots.isPending && !days.length && <Text style={t.styles.text.muted}>No open times in the next two weeks.</Text>}
|
|
38
|
+
{days.map((d) => (
|
|
39
|
+
<View key={d.date} style={{ gap: 10 }}>
|
|
40
|
+
<Text style={t.styles.text.heading}>{day(d.date)}</Text>
|
|
41
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
42
|
+
{d.slots.map((s) => (
|
|
43
|
+
<Pressable
|
|
44
|
+
key={s.start}
|
|
45
|
+
accessibilityRole="button"
|
|
46
|
+
onPress={() => router.push({ pathname: '/book/details', params: { resource, link, start: s.start, end: s.end, minutes: String(s.minutes) } })}
|
|
47
|
+
style={({ pressed }) => ({ minWidth: 96, paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: t.colors.line, backgroundColor: pressed ? t.colors.soft : t.colors.panel, alignItems: 'center' })}
|
|
48
|
+
>
|
|
49
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{time(s.start)}</Text>
|
|
50
|
+
</Pressable>
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
</View>
|
|
54
|
+
))}
|
|
55
|
+
</ScrollView>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { KeyboardAvoidingView, Platform, Pressable, ScrollView, Switch, Text, TextInput, View, type KeyboardTypeOptions } from 'react-native';
|
|
3
|
+
import { Stack, router, useLocalSearchParams } from 'expo-router';
|
|
4
|
+
import * as Crypto from 'expo-crypto';
|
|
5
|
+
import { isAcmsError } from '@acms-subir/client';
|
|
6
|
+
import { Button, formatEventDate, useBooking, useLayout, useLocale, useResources, useTheme } from '@acms-subir/native';
|
|
7
|
+
|
|
8
|
+
// Details form and confirmation. useBooking keeps one idempotency key per
|
|
9
|
+
// booking intent, so pressing "Book" again after a timeout can't double-book.
|
|
10
|
+
export default function DetailsScreen() {
|
|
11
|
+
const t = useTheme();
|
|
12
|
+
const { locale } = useLocale();
|
|
13
|
+
const { gutter } = useLayout();
|
|
14
|
+
const p = useLocalSearchParams<{ resource: string; link: string; start: string; end: string; minutes: string }>();
|
|
15
|
+
const r = useResources().data?.resources.find((x) => x.slug === p.resource);
|
|
16
|
+
const l = r?.links.find((x) => x.slug === p.link);
|
|
17
|
+
const booking = useBooking({ createKey: Crypto.randomUUID });
|
|
18
|
+
const [name, setName] = useState('');
|
|
19
|
+
const [email, setEmail] = useState('');
|
|
20
|
+
const [phone, setPhone] = useState('');
|
|
21
|
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
|
22
|
+
const when = formatEventDate({ start: p.start, end: p.end, timezone: r?.timezone }, locale);
|
|
23
|
+
|
|
24
|
+
const input = { borderWidth: 1, borderColor: t.colors.line, borderRadius: Math.min(t.radius, 10), padding: 12, fontSize: 16, color: t.colors.ink, backgroundColor: t.colors.panel } as const;
|
|
25
|
+
const field = (label: string, value: string, onChange: (v: string) => void, opts: { keyboard?: KeyboardTypeOptions; multiline?: boolean; auto?: 'name' | 'email' | 'tel' } = {}) => (
|
|
26
|
+
<View key={label} style={{ gap: 6 }}>
|
|
27
|
+
<Text style={t.styles.text.label}>{label}</Text>
|
|
28
|
+
<TextInput value={value} onChangeText={onChange} keyboardType={opts.keyboard} multiline={opts.multiline} autoComplete={opts.auto}
|
|
29
|
+
autoCapitalize={opts.auto === 'email' ? 'none' : 'sentences'} placeholderTextColor={t.colors.muted}
|
|
30
|
+
style={[input, opts.multiline && { minHeight: 96, textAlignVertical: 'top' }]} />
|
|
31
|
+
</View>
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
if (booking.data) {
|
|
35
|
+
const b = booking.data.booking;
|
|
36
|
+
return (
|
|
37
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 12, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
38
|
+
<Stack.Screen options={{ title: 'Booked', headerBackVisible: false }} />
|
|
39
|
+
<Text accessibilityRole="header" style={t.styles.text.display}>{b.status === 'confirmed' ? 'You’re booked' : 'Request sent'}</Text>
|
|
40
|
+
<Text style={t.styles.text.body}>{l?.name} · {when}</Text>
|
|
41
|
+
<Text style={t.styles.text.muted}>
|
|
42
|
+
{b.status === 'confirmed' ? `A confirmation is on its way to ${b.email}.` : `We’ll email ${b.email} once it’s confirmed.`}
|
|
43
|
+
</Text>
|
|
44
|
+
<Button label="Done" onPress={() => { booking.reset(); router.dismissAll(); }} />
|
|
45
|
+
</ScrollView>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const error = booking.error;
|
|
50
|
+
const submit = () => booking.mutate({ resource: p.resource, link: p.link, start: p.start, minutes: Number(p.minutes) || undefined, name: name.trim(), email: email.trim(), ...(phone.trim() ? { phone: phone.trim() } : {}), answers });
|
|
51
|
+
return (
|
|
52
|
+
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
|
|
53
|
+
<ScrollView keyboardShouldPersistTaps="handled" style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 640, alignSelf: 'center' }}>
|
|
54
|
+
<Stack.Screen options={{ title: 'Your details' }} />
|
|
55
|
+
<View style={[t.styles.card, { gap: 2 }]}>
|
|
56
|
+
<Text style={t.styles.text.heading}>{l?.name ?? 'Booking'}</Text>
|
|
57
|
+
<Text style={t.styles.text.muted}>{r?.name} · {when}</Text>
|
|
58
|
+
</View>
|
|
59
|
+
{field('Name', name, setName, { auto: 'name' })}
|
|
60
|
+
{field('Email', email, setEmail, { keyboard: 'email-address', auto: 'email' })}
|
|
61
|
+
{l?.askPhone && field('Phone', phone, setPhone, { keyboard: 'phone-pad', auto: 'tel' })}
|
|
62
|
+
{l?.questions.map((q) => {
|
|
63
|
+
const label = q.label + (q.required ? '' : ' (optional)');
|
|
64
|
+
const set = (v: string) => setAnswers((a) => ({ ...a, [q.id]: v }));
|
|
65
|
+
if (q.type === 'checkbox') return (
|
|
66
|
+
<View key={q.id} style={{ flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: 12 }}>
|
|
67
|
+
<Text style={[t.styles.text.body, { flex: 1 }]}>{label}</Text>
|
|
68
|
+
<Switch value={answers[q.id] === 'yes'} onValueChange={(v) => set(v ? 'yes' : '')} trackColor={{ true: t.colors.accent }} />
|
|
69
|
+
</View>
|
|
70
|
+
);
|
|
71
|
+
if (q.type === 'select') return (
|
|
72
|
+
<View key={q.id} style={{ gap: 6 }}>
|
|
73
|
+
<Text style={t.styles.text.label}>{label}</Text>
|
|
74
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
|
75
|
+
{q.options.map((o) => {
|
|
76
|
+
const on = answers[q.id] === o;
|
|
77
|
+
return (
|
|
78
|
+
<Pressable key={o} accessibilityRole="radio" accessibilityState={{ checked: on }} onPress={() => set(o)}
|
|
79
|
+
style={{ paddingVertical: 10, paddingHorizontal: 12, borderRadius: Math.min(t.radius, 10), borderWidth: 1, borderColor: on ? t.colors.accent : t.colors.line, backgroundColor: on ? t.colors.soft : t.colors.panel }}>
|
|
80
|
+
<Text style={[t.styles.text.body, on && { fontWeight: '600' }]}>{o}</Text>
|
|
81
|
+
</Pressable>
|
|
82
|
+
);
|
|
83
|
+
})}
|
|
84
|
+
</View>
|
|
85
|
+
</View>
|
|
86
|
+
);
|
|
87
|
+
return field(label, answers[q.id] ?? '', set, {
|
|
88
|
+
multiline: q.type === 'textarea',
|
|
89
|
+
keyboard: q.type === 'email' ? 'email-address' : q.type === 'phone' ? 'phone-pad' : q.type === 'number' ? 'numeric' : undefined,
|
|
90
|
+
});
|
|
91
|
+
})}
|
|
92
|
+
{error && (
|
|
93
|
+
<View style={{ backgroundColor: t.colors.soft, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 4 }}>
|
|
94
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>That didn’t go through.</Text>
|
|
95
|
+
<Text style={t.styles.text.muted}>
|
|
96
|
+
{isAcmsError(error) && error.status === 0 ? 'Check your connection and try again. You won’t be booked twice.' : error.message}
|
|
97
|
+
</Text>
|
|
98
|
+
</View>
|
|
99
|
+
)}
|
|
100
|
+
<Button label={booking.isPending ? 'Booking…' : 'Book'} disabled={booking.isPending || !name.trim() || !email.trim()} onPress={submit} />
|
|
101
|
+
<Text style={t.styles.text.small}>Your details go only to the organiser of this booking.</Text>
|
|
102
|
+
</ScrollView>
|
|
103
|
+
</KeyboardAvoidingView>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ActivityIndicator, Pressable, ScrollView, Text, View } from 'react-native';
|
|
2
|
+
import { Stack, router } from 'expo-router';
|
|
3
|
+
import { ErrorView, useLayout, useResources, useTheme } from '@acms-subir/native';
|
|
4
|
+
|
|
5
|
+
// Everything the site lets people book, grouped by resource.
|
|
6
|
+
export default function BookScreen() {
|
|
7
|
+
const t = useTheme();
|
|
8
|
+
const { gutter, wide } = useLayout();
|
|
9
|
+
const { data, error, isPending, refetch } = useResources();
|
|
10
|
+
if (error && !data) return <ErrorView error={error} retry={refetch} />;
|
|
11
|
+
const resources = (data?.resources ?? []).filter((r) => r.links.length);
|
|
12
|
+
return (
|
|
13
|
+
<ScrollView style={{ backgroundColor: t.colors.bg }} contentContainerStyle={{ padding: gutter, gap: 16, width: '100%', maxWidth: 1040, alignSelf: 'center' }}>
|
|
14
|
+
<Stack.Screen options={{ title: 'Book' }} />
|
|
15
|
+
{isPending && <ActivityIndicator color={t.colors.accent} />}
|
|
16
|
+
{!isPending && !resources.length && <Text style={t.styles.text.muted}>Nothing is open for booking right now.</Text>}
|
|
17
|
+
<View style={{ flexDirection: wide ? 'row' : 'column', flexWrap: 'wrap', gap: 16 }}>
|
|
18
|
+
{resources.map((r) => (
|
|
19
|
+
<View key={r.id} style={[t.styles.card, { gap: 10, width: wide ? '48.5%' : '100%' }]}>
|
|
20
|
+
<Text style={t.styles.text.heading}>{r.name}</Text>
|
|
21
|
+
{!!r.description && <Text style={t.styles.text.muted}>{r.description}</Text>}
|
|
22
|
+
{r.links.map((l) => (
|
|
23
|
+
<Pressable
|
|
24
|
+
key={l.id}
|
|
25
|
+
accessibilityRole="button"
|
|
26
|
+
onPress={() => router.push({ pathname: '/book/[resource]', params: { resource: r.slug, link: l.slug } })}
|
|
27
|
+
style={({ pressed }) => ({ borderColor: t.colors.line, borderWidth: 1, borderRadius: Math.min(t.radius, 10), padding: 12, gap: 2, opacity: pressed ? 0.85 : 1 })}
|
|
28
|
+
>
|
|
29
|
+
<Text style={[t.styles.text.body, { fontWeight: '600' }]}>{l.name}</Text>
|
|
30
|
+
<Text style={t.styles.text.small}>{l.lengths.length ? l.lengths.map((m) => `${m} min`).join(' · ') : l.mode}</Text>
|
|
31
|
+
</Pressable>
|
|
32
|
+
))}
|
|
33
|
+
</View>
|
|
34
|
+
))}
|
|
35
|
+
</View>
|
|
36
|
+
</ScrollView>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "client",
|
|
3
|
+
"title": "Booking",
|
|
4
|
+
"description": "Resources and booking links → open slots → details form → confirmation, with retry-safe submits.",
|
|
5
|
+
"packages": [
|
|
6
|
+
"expo-crypto"
|
|
7
|
+
],
|
|
8
|
+
"routes": [
|
|
9
|
+
"/book",
|
|
10
|
+
"/book/[resource]",
|
|
11
|
+
"/book/details"
|
|
12
|
+
],
|
|
13
|
+
"next": [
|
|
14
|
+
"Link to it from a screen: <Link href=\"/book\">Book</Link> (expo-router)",
|
|
15
|
+
"Bookings must be enabled on the site (Admin → Bookings) with at least one public booking link."
|
|
16
|
+
]
|
|
17
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, FlatList, RefreshControl, ScrollView, Text, View } from 'react-native';
|
|
3
|
+
import { Stack } from 'expo-router';
|
|
4
|
+
import type { EditorBooking } from '@acms-subir/client';
|
|
5
|
+
import { Card, EditorButton, EditorInput, ListRow, Notice, Sheet, StatusText, errorMessage, useBookingsInbox, useEditorTheme, useMe } from '@acms-subir/native/editor';
|
|
6
|
+
import { SplitView, useWide } from '../../components/Screen';
|
|
7
|
+
import { TextTabs } from '../../components/TextTabs';
|
|
8
|
+
import { dateTime } from '../../lib/format';
|
|
9
|
+
|
|
10
|
+
type Filter = 'pending' | 'active' | 'confirmed' | 'cancelled' | 'all';
|
|
11
|
+
type Action = 'approve' | 'decline' | 'cancel';
|
|
12
|
+
|
|
13
|
+
export default function BookingsScreen() {
|
|
14
|
+
const t = useEditorTheme();
|
|
15
|
+
const { wide, gutter } = useWide();
|
|
16
|
+
const me = useMe();
|
|
17
|
+
const [filter, setFilter] = useState<Filter>('active');
|
|
18
|
+
const inbox = useBookingsInbox({ status: filter });
|
|
19
|
+
const [selected, setSelected] = useState<string | null>(null);
|
|
20
|
+
const [asking, setAsking] = useState<{ booking: EditorBooking; action: Action } | null>(null);
|
|
21
|
+
const current = inbox.items.find((b) => b.id === selected) ?? null;
|
|
22
|
+
if (me.isSuccess && !me.data.permissions.bookings) return <View style={{ flex: 1, padding: 16, backgroundColor: t.colors.panel }}><Stack.Screen options={{ title: 'Bookings' }} /><Notice>Bookings are off for this site.</Notice></View>;
|
|
23
|
+
const list = (
|
|
24
|
+
<FlatList
|
|
25
|
+
data={inbox.items}
|
|
26
|
+
keyExtractor={(b) => b.id}
|
|
27
|
+
contentContainerStyle={{ padding: gutter, gap: 2 }}
|
|
28
|
+
refreshControl={<RefreshControl refreshing={inbox.isRefetching && !inbox.isFetchingNextPage} onRefresh={() => inbox.refetch()} tintColor={t.colors.muted} />}
|
|
29
|
+
onEndReachedThreshold={0.4}
|
|
30
|
+
onEndReached={() => { if (inbox.hasNextPage && !inbox.isFetchingNextPage) void inbox.fetchNextPage(); }}
|
|
31
|
+
ListHeaderComponent={
|
|
32
|
+
<View style={{ gap: 8, marginBottom: 8 }}>
|
|
33
|
+
<TextTabs<Filter> accessibilityLabel="Show" value={filter} onChange={(f) => { setFilter(f); setSelected(null); }} options={[
|
|
34
|
+
{ value: 'active', label: 'Inbox' }, { value: 'pending', label: 'Pending', count: inbox.pending || undefined }, { value: 'confirmed', label: 'Confirmed' },
|
|
35
|
+
{ value: 'cancelled', label: 'Cancelled' }, { value: 'all', label: 'All' },
|
|
36
|
+
]} />
|
|
37
|
+
{!!inbox.pending && filter === 'active' && <Text style={[t.text.meta, { color: t.colors.accentText }]}>{inbox.pending} waiting for an answer</Text>}
|
|
38
|
+
</View>
|
|
39
|
+
}
|
|
40
|
+
ListEmptyComponent={inbox.isPending ? <ActivityIndicator color={t.colors.muted} /> : inbox.error ? <Notice tone="error">{errorMessage(inbox.error)}</Notice> : <Notice>No bookings here.</Notice>}
|
|
41
|
+
renderItem={({ item }) => (
|
|
42
|
+
wide ? (
|
|
43
|
+
<ListRow title={item.name} selected={selected === item.id} onPress={() => setSelected(item.id)}
|
|
44
|
+
meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={item.status} />{` ${dateTime(item.start)} · ${item.title}`}</Text>} />
|
|
45
|
+
) : (
|
|
46
|
+
<BookingCard booking={item} expanded={selected === item.id} onToggle={() => setSelected(selected === item.id ? null : item.id)} onAct={(action) => setAsking({ booking: item, action })} acting={inbox.acting?.id === item.id} />
|
|
47
|
+
)
|
|
48
|
+
)}
|
|
49
|
+
/>
|
|
50
|
+
);
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<Stack.Screen options={{ title: 'Bookings' }} />
|
|
54
|
+
<SplitView list={list} detail={current ? (
|
|
55
|
+
<ScrollView contentContainerStyle={{ padding: 24, maxWidth: 720 }}>
|
|
56
|
+
<BookingCard booking={current} expanded onAct={(action) => setAsking({ booking: current, action })} acting={inbox.acting?.id === current.id} />
|
|
57
|
+
</ScrollView>
|
|
58
|
+
) : <View style={{ flex: 1, alignItems: 'center', justifyContent: 'center' }}><Text style={t.text.meta}>Choose a booking on the left.</Text></View>} />
|
|
59
|
+
{asking && <AnswerSheet {...asking} onClose={() => setAsking(null)} onConfirm={async (message) => {
|
|
60
|
+
const { booking, action } = asking;
|
|
61
|
+
await (action === 'approve' ? inbox.approve(booking.id, message) : action === 'decline' ? inbox.decline(booking.id, message) : inbox.cancel(booking.id, message));
|
|
62
|
+
setAsking(null);
|
|
63
|
+
}} />}
|
|
64
|
+
</>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function BookingCard({ booking: b, expanded, onToggle, onAct, acting }: { booking: EditorBooking; expanded: boolean; onToggle?: () => void; onAct: (a: Action) => void; acting: boolean }) {
|
|
69
|
+
const t = useEditorTheme();
|
|
70
|
+
const upcoming = new Date(b.end).getTime() > Date.now();
|
|
71
|
+
return (
|
|
72
|
+
<Card style={{ marginBottom: 8 }}>
|
|
73
|
+
<ListRow title={b.name} onPress={onToggle} accessibilityLabel={`${b.name}, ${b.status}, ${dateTime(b.start)}`}
|
|
74
|
+
meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={b.status} />{` ${dateTime(b.start)}`}</Text>}
|
|
75
|
+
right={b.seats > 1 ? <Text style={t.text.meta}>{b.seats} seats</Text> : undefined} />
|
|
76
|
+
{expanded && (
|
|
77
|
+
<View style={{ paddingHorizontal: 14, paddingBottom: 14, gap: 8 }}>
|
|
78
|
+
<Text style={t.text.body}>{b.title}{b.kind === 'rsvp' ? ' (RSVP)' : ''}</Text>
|
|
79
|
+
<Text selectable style={t.text.body}>{b.email}{b.phone ? `\n${b.phone}` : ''}</Text>
|
|
80
|
+
{Object.entries(b.answers).map(([q, a]) => <Text key={q} style={t.text.meta}><Text style={{ fontWeight: '600', color: t.colors.text2 }}>{q}: </Text>{a}</Text>)}
|
|
81
|
+
{!!b.notes && <Text style={t.text.meta}>Notes: {b.notes}</Text>}
|
|
82
|
+
<View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap', marginTop: 4 }}>
|
|
83
|
+
{b.status === 'pending' && <EditorButton variant="primary" label="Approve" busy={acting} onPress={() => onAct('approve')} />}
|
|
84
|
+
{b.status === 'pending' && <EditorButton label="Decline" disabled={acting} onPress={() => onAct('decline')} />}
|
|
85
|
+
{b.status === 'confirmed' && upcoming && <EditorButton variant="danger" label="Cancel booking" disabled={acting} onPress={() => onAct('cancel')} />}
|
|
86
|
+
</View>
|
|
87
|
+
</View>
|
|
88
|
+
)}
|
|
89
|
+
</Card>
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function AnswerSheet({ booking, action, onClose, onConfirm }: { booking: EditorBooking; action: Action; onClose: () => void; onConfirm: (message?: string) => Promise<void> }) {
|
|
94
|
+
const t = useEditorTheme();
|
|
95
|
+
const [message, setMessage] = useState('');
|
|
96
|
+
const [busy, setBusy] = useState(false);
|
|
97
|
+
const [error, setError] = useState<string | null>(null);
|
|
98
|
+
const verb = action === 'approve' ? 'Approve' : action === 'decline' ? 'Decline' : 'Cancel booking';
|
|
99
|
+
return (
|
|
100
|
+
<Sheet visible onClose={onClose} title={`${verb}: ${booking.name}`} footer={
|
|
101
|
+
<EditorButton variant={action === 'approve' ? 'primary' : 'danger'} label={verb} busy={busy} onPress={async () => {
|
|
102
|
+
setBusy(true); setError(null);
|
|
103
|
+
try { await onConfirm(message.trim() || undefined); } catch (e) { setError(errorMessage(e)); } finally { setBusy(false); }
|
|
104
|
+
}} />
|
|
105
|
+
}>
|
|
106
|
+
<Text style={t.text.body}>{booking.title} · {dateTime(booking.start)}</Text>
|
|
107
|
+
<Text style={t.text.label}>Message to {booking.name} (optional)</Text>
|
|
108
|
+
<EditorInput multiline value={message} onChangeText={setMessage} placeholder={action === 'approve' ? 'See you then!' : 'Sorry, that time doesn’t work.'} accessibilityLabel="Message" maxLength={1000} />
|
|
109
|
+
<Text style={t.text.meta}>{booking.name} gets an email{action === 'approve' ? ' confirming the booking' : ''}.</Text>
|
|
110
|
+
{!!error && <Notice tone="error">{error}</Notice>}
|
|
111
|
+
</Sheet>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { title: 'Bookings', description: 'Approve and answer requests', href: '/bookings', order: 60 };
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"kind": "editor",
|
|
3
|
+
"title": "Bookings",
|
|
4
|
+
"description": "The bookings inbox: pending requests first, then upcoming; approve, decline or cancel with an optional message to the guest. Tablets show list + detail.",
|
|
5
|
+
"packages": [],
|
|
6
|
+
"routes": ["/bookings"],
|
|
7
|
+
"next": ["It appears on Home (tools/bookings.ts). Bookings must be on for the site (calendar + booking links)."]
|
|
8
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { Stack, useLocalSearchParams } from 'expo-router';
|
|
2
|
+
import type { DocKind } from '@acms-subir/client';
|
|
3
|
+
import { DocumentDetail } from '../../../components/DocumentDetail';
|
|
4
|
+
|
|
5
|
+
export default function ContentDetailScreen() {
|
|
6
|
+
const { kind, id } = useLocalSearchParams<{ kind: DocKind; id: string }>();
|
|
7
|
+
return (
|
|
8
|
+
<>
|
|
9
|
+
<Stack.Screen options={{ title: kind === 'page' ? 'Page' : kind === 'event' ? 'Event' : 'Post' }} />
|
|
10
|
+
<DocumentDetail kind={kind} id={id} />
|
|
11
|
+
</>
|
|
12
|
+
);
|
|
13
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ActivityIndicator, FlatList, RefreshControl, Text, View } from 'react-native';
|
|
3
|
+
import { Stack, router } from 'expo-router';
|
|
4
|
+
import type { DocKind } from '@acms-subir/client';
|
|
5
|
+
import { EditorButton, EditorInput, ListRow, Notice, StatusText, errorMessage, useDocuments, useEditorTheme, useMe } from '@acms-subir/native/editor';
|
|
6
|
+
import { HeaderActions, SplitView, useWide } from '../../components/Screen';
|
|
7
|
+
import { TextTabs } from '../../components/TextTabs';
|
|
8
|
+
import { DocumentDetail } from '../../components/DocumentDetail';
|
|
9
|
+
import { hasTool } from '../../lib/tools';
|
|
10
|
+
import { ago } from '../../lib/format';
|
|
11
|
+
|
|
12
|
+
type Status = 'all' | 'draft' | 'scheduled' | 'published' | 'changed';
|
|
13
|
+
|
|
14
|
+
export default function ContentScreen() {
|
|
15
|
+
const t = useEditorTheme();
|
|
16
|
+
const { wide, gutter } = useWide();
|
|
17
|
+
const me = useMe();
|
|
18
|
+
const [kind, setKind] = useState<DocKind>('post');
|
|
19
|
+
const [collection, setCollection] = useState<string>('');
|
|
20
|
+
const [status, setStatus] = useState<Status>('all');
|
|
21
|
+
const [q, setQ] = useState('');
|
|
22
|
+
const [selected, setSelected] = useState<{ kind: DocKind; id: string } | null>(null);
|
|
23
|
+
const collections = me.data?.features.collections ?? [];
|
|
24
|
+
const docs = useDocuments({ kind, status: status === 'all' ? undefined : status, q: q.trim() || undefined, collection: kind === 'post' && collection ? collection : undefined, limit: 30 });
|
|
25
|
+
const kinds: { value: DocKind; label: string }[] = [
|
|
26
|
+
{ value: 'post', label: collections.length === 1 ? collections[0].plural : 'Posts' },
|
|
27
|
+
{ value: 'page', label: 'Pages' },
|
|
28
|
+
...(me.data?.features.calendar ? [{ value: 'event' as const, label: 'Events' }] : []),
|
|
29
|
+
];
|
|
30
|
+
const open = (d: { kind: DocKind; id: string }) => (wide ? setSelected(d) : router.push({ pathname: '/content/[kind]/[id]', params: d }));
|
|
31
|
+
|
|
32
|
+
const list = (
|
|
33
|
+
<FlatList
|
|
34
|
+
data={docs.items}
|
|
35
|
+
keyExtractor={(d) => d.id}
|
|
36
|
+
contentContainerStyle={{ padding: gutter, gap: 2 }}
|
|
37
|
+
keyboardShouldPersistTaps="handled"
|
|
38
|
+
refreshControl={<RefreshControl refreshing={docs.isRefetching && !docs.isFetchingNextPage} onRefresh={() => docs.refetch()} tintColor={t.colors.muted} />}
|
|
39
|
+
onEndReachedThreshold={0.4}
|
|
40
|
+
onEndReached={() => { if (docs.hasNextPage && !docs.isFetchingNextPage) void docs.fetchNextPage(); }}
|
|
41
|
+
ListHeaderComponent={
|
|
42
|
+
<View style={{ gap: 10, marginBottom: 8 }}>
|
|
43
|
+
<TextTabs accessibilityLabel="Kind" value={kind} options={kinds} onChange={(k) => { setKind(k); setSelected(null); }} />
|
|
44
|
+
{kind === 'post' && collections.length > 1 && (
|
|
45
|
+
<TextTabs accessibilityLabel="Collection" value={collection} onChange={setCollection}
|
|
46
|
+
options={[{ value: '', label: 'All collections' }, ...collections.map((c) => ({ value: c.key, label: c.plural }))]} />
|
|
47
|
+
)}
|
|
48
|
+
<TextTabs<Status> accessibilityLabel="Status" value={status} onChange={setStatus} options={[
|
|
49
|
+
{ value: 'all', label: 'All' }, { value: 'draft', label: 'Drafts' }, { value: 'scheduled', label: 'Scheduled' },
|
|
50
|
+
{ value: 'published', label: 'Published' }, { value: 'changed', label: 'Changed' },
|
|
51
|
+
]} />
|
|
52
|
+
<EditorInput placeholder="Search titles" value={q} onChangeText={setQ} accessibilityLabel="Search" autoCapitalize="none" returnKeyType="search" clearButtonMode="while-editing" />
|
|
53
|
+
{docs.isSuccess && <Text style={t.text.meta}>{docs.total} {docs.total === 1 ? 'item' : 'items'}</Text>}
|
|
54
|
+
</View>
|
|
55
|
+
}
|
|
56
|
+
ListEmptyComponent={docs.isPending ? <ActivityIndicator color={t.colors.muted} /> : docs.error ? <Notice tone="error">{errorMessage(docs.error)}</Notice> : <Notice>Nothing here yet.</Notice>}
|
|
57
|
+
ListFooterComponent={docs.isFetchingNextPage ? <ActivityIndicator style={{ margin: 16 }} color={t.colors.muted} /> : null}
|
|
58
|
+
renderItem={({ item }) => (
|
|
59
|
+
<ListRow
|
|
60
|
+
title={item.title || 'Untitled'}
|
|
61
|
+
selected={wide && selected?.id === item.id}
|
|
62
|
+
onPress={() => open({ kind: item.kind, id: item.id })}
|
|
63
|
+
meta={<Text numberOfLines={1} style={t.text.meta}><StatusText status={item.status} suffix={item.hasChanges ? 'edited' : undefined} />{` ${ago(item.updatedAt)}`}</Text>}
|
|
64
|
+
/>
|
|
65
|
+
)}
|
|
66
|
+
/>
|
|
67
|
+
);
|
|
68
|
+
return (
|
|
69
|
+
<>
|
|
70
|
+
<Stack.Screen options={{ title: 'Content', headerRight: hasTool('writer') ? () => <HeaderActions><EditorButton label="New" variant="primary" compact onPress={() => router.push('/write/new')} /></HeaderActions> : undefined }} />
|
|
71
|
+
<SplitView list={list} detail={selected ? <DocumentDetail key={selected.id} kind={selected.kind} id={selected.id} /> : (
|
|
72
|
+
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', padding: 24 }}><Text style={t.text.meta}>Choose something on the left.</Text></View>
|
|
73
|
+
)} />
|
|
74
|
+
</>
|
|
75
|
+
);
|
|
76
|
+
}
|