@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,39 @@
|
|
|
1
|
+
import type { DocTranslation, DocTranslations } from './schema';
|
|
2
|
+
import { I18N } from './i18n';
|
|
3
|
+
|
|
4
|
+
// Posts, pages and events in another language: the translated words laid
|
|
5
|
+
// over the document; anything not translated yet stays in the main language.
|
|
6
|
+
// Facts (dates, covers, tags, places' addresses…) are never translated.
|
|
7
|
+
|
|
8
|
+
type Doc = { title: string; bodyHtml: string; seoTitle: string; seoDesc: string; excerpt?: string; translations?: DocTranslations; location?: { name: string }; cta?: { label: string } };
|
|
9
|
+
|
|
10
|
+
export const isExtraLocale = (locale: string | undefined | null): locale is string =>
|
|
11
|
+
Boolean(locale) && I18N.enabled && locale !== I18N.defaultLocale && I18N.locales.includes(locale as string);
|
|
12
|
+
|
|
13
|
+
export function translationFor(doc: { translations?: DocTranslations }, locale: string): DocTranslation | null {
|
|
14
|
+
if (!isExtraLocale(locale)) return null;
|
|
15
|
+
return doc.translations?.[locale] ?? null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** does this document have any words in the language? */
|
|
19
|
+
export const hasTranslation = (doc: { translations?: DocTranslations }, locale: string) => {
|
|
20
|
+
const t = translationFor(doc, locale);
|
|
21
|
+
return Boolean(t && (t.title.trim() || t.bodyHtml.trim()));
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export function localizeDoc<T extends Doc>(doc: T, locale: string | undefined): T {
|
|
25
|
+
const t = locale ? translationFor(doc, locale) : null;
|
|
26
|
+
if (!t) return doc;
|
|
27
|
+
const pick = (v: string, fallback: string) => (v && v.trim() ? v : fallback);
|
|
28
|
+
const out: T = {
|
|
29
|
+
...doc,
|
|
30
|
+
title: pick(t.title, doc.title),
|
|
31
|
+
bodyHtml: pick(t.bodyHtml, doc.bodyHtml),
|
|
32
|
+
seoTitle: pick(t.seoTitle, doc.seoTitle),
|
|
33
|
+
seoDesc: pick(t.seoDesc, doc.seoDesc),
|
|
34
|
+
};
|
|
35
|
+
if (doc.excerpt !== undefined) (out as Doc).excerpt = pick(t.excerpt, doc.excerpt);
|
|
36
|
+
if (doc.location) (out as Doc).location = { ...doc.location, name: pick(t.locationName, doc.location.name) };
|
|
37
|
+
if (doc.cta) (out as Doc).cta = { ...doc.cta, label: pick(t.ctaLabel, doc.cta.label) };
|
|
38
|
+
return out;
|
|
39
|
+
}
|
package/src/middleware.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { defineMiddleware } from 'astro:middleware';
|
|
2
|
+
import { I18N } from './lib/i18n';
|
|
2
3
|
import { validateSession, SESSION_COOKIE } from './lib/auth';
|
|
3
4
|
import { getRepo } from './lib/repo';
|
|
4
5
|
import { CONTENT } from './lib/content';
|
|
5
6
|
import { processDueDeliveries, waitUntilFrom } from './lib/webhooks';
|
|
6
7
|
import { dispatchEmail } from './lib/email/service';
|
|
7
|
-
import { sendDueReminders } from './lib/booking/service';
|
|
8
|
+
import { decideOverdue, sendDueReminders } from './lib/booking/service';
|
|
9
|
+
import { PUSH } from './lib/push/config';
|
|
10
|
+
import { processPushQueue, runPushJobs } from './lib/push/service';
|
|
8
11
|
import { previewParamRedirect, hasPreviewCookie } from './lib/designPreview';
|
|
9
12
|
|
|
10
13
|
// Legacy content-URL redirects, checked only on would-be 404s (below) so they
|
|
@@ -60,6 +63,20 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
60
63
|
return context.redirect(pathname.replace(/\/+$/, '') + context.url.search, 301);
|
|
61
64
|
}
|
|
62
65
|
|
|
66
|
+
// another language: /es/events/x renders /events/x with the request marked
|
|
67
|
+
// Spanish (pages lay translated words over the shared content). The URL the
|
|
68
|
+
// visitor sees stays /es/…; /es itself is the injected home route.
|
|
69
|
+
if (I18N.enabled && context.request.method !== 'OPTIONS') {
|
|
70
|
+
for (const l of I18N.locales) {
|
|
71
|
+
if (l === I18N.defaultLocale || !pathname.startsWith(`/${l}/`)) continue;
|
|
72
|
+
const bare = pathname.slice(l.length + 1);
|
|
73
|
+
if (/^\/(admin|api|_|media|embed|go)(\/|$)/.test(bare)) break;
|
|
74
|
+
(context.locals as { acmsLocale?: string; acmsPath?: string }).acmsLocale = l;
|
|
75
|
+
(context.locals as { acmsLocale?: string; acmsPath?: string }).acmsPath = pathname;
|
|
76
|
+
return context.rewrite(new URL(bare + context.url.search, context.url));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
63
80
|
// design preview links (?designPreview=1, ?designVersion=, ?preview=<token>,
|
|
64
81
|
// ?preview=off) become a cookie, then the clean URL — so every link clicked
|
|
65
82
|
// afterwards stays in preview mode
|
|
@@ -70,6 +87,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
70
87
|
if (pathname.startsWith('/admin') && pathname !== '/admin/login') {
|
|
71
88
|
const token = context.cookies.get(SESSION_COOKIE)?.value;
|
|
72
89
|
if (!(await validateSession(token, context.locals))) {
|
|
90
|
+
// editor-app sign-in comes back here after login (see login.astro's next)
|
|
91
|
+
if (pathname === '/admin/app-auth' && context.request.method === 'GET') {
|
|
92
|
+
return context.redirect(`/admin/login?next=${encodeURIComponent(pathname + context.url.search)}`);
|
|
93
|
+
}
|
|
73
94
|
return context.redirect('/admin/login');
|
|
74
95
|
}
|
|
75
96
|
}
|
|
@@ -89,9 +110,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
89
110
|
if (wait) wait(retries); else await retries;
|
|
90
111
|
// booking reminders (Node has no cron): site URL, else this request's origin
|
|
91
112
|
const reminders = repo.getSettings()
|
|
92
|
-
.then((s) =>
|
|
113
|
+
.then(async (s) => {
|
|
114
|
+
const flow = { origin: s.siteUrl.replace(/\/+$/, '') || context.url.origin, locals: context.locals, now };
|
|
115
|
+
await sendDueReminders(repo, flow);
|
|
116
|
+
await decideOverdue(repo, flow);
|
|
117
|
+
})
|
|
93
118
|
.catch(() => {});
|
|
94
119
|
if (wait) wait(reminders); else await reminders;
|
|
120
|
+
// push jobs (Node has no cron): never awaited here, errors are logged
|
|
121
|
+
const push = runPushJobs(repo, { locals: context.locals, fallbackOrigin: context.url.origin, now })
|
|
122
|
+
.catch((e) => console.error('acms push: scheduled jobs failed', e));
|
|
123
|
+
if (wait) wait(push);
|
|
95
124
|
} catch {
|
|
96
125
|
// never let the publish sweep break a request
|
|
97
126
|
}
|
|
@@ -106,6 +135,13 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
106
135
|
const deliveries = getRepo(context.locals).then(repo => processDueDeliveries(repo, { limit: 5 })).catch(() => 0);
|
|
107
136
|
const wait = waitUntilFrom(context.locals);
|
|
108
137
|
if (wait) wait(deliveries); else await deliveries;
|
|
138
|
+
// first-publish push notices: background only, never delaying the response
|
|
139
|
+
if (PUSH.enabled) {
|
|
140
|
+
const push = getRepo(context.locals)
|
|
141
|
+
.then(repo => processPushQueue(repo, { locals: context.locals, fallbackOrigin: context.url.origin }))
|
|
142
|
+
.catch((e) => console.error('acms push: queue flush failed', e));
|
|
143
|
+
if (wait) wait(push);
|
|
144
|
+
}
|
|
109
145
|
}
|
|
110
146
|
|
|
111
147
|
// a preview render must never be cached where a visitor could get it
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { associationRoute } from '../../lib/mobile/associations';
|
|
2
|
+
import { methodNotAllowed } from '../../lib/mobile/http';
|
|
3
|
+
export { preflight as OPTIONS } from '../../lib/mobile/http';
|
|
4
|
+
export const GET = associationRoute('ios');
|
|
5
|
+
export const HEAD = GET;
|
|
6
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { associationRoute } from '../../lib/mobile/associations';
|
|
2
|
+
import { methodNotAllowed } from '../../lib/mobile/http';
|
|
3
|
+
export { preflight as OPTIONS } from '../../lib/mobile/http';
|
|
4
|
+
export const GET = associationRoute('android');
|
|
5
|
+
export const HEAD = GET;
|
|
6
|
+
export const ALL = () => methodNotAllowed();
|
|
@@ -8,10 +8,14 @@ import { expandDynamicBlocks } from '../lib/dynamicBlocks';
|
|
|
8
8
|
import { highlightCodeHtml } from '../lib/highlightCode';
|
|
9
9
|
import { resolveDesign } from '../lib/theme';
|
|
10
10
|
import { effectiveSettings } from '../lib/designPreview';
|
|
11
|
+
import { requestLocale } from '../lib/i18n';
|
|
12
|
+
import { localizeDoc, isExtraLocale } from '../lib/translations';
|
|
11
13
|
|
|
12
14
|
const { slug } = Astro.params;
|
|
13
15
|
const repo = await getRepo(Astro.locals);
|
|
14
|
-
const
|
|
16
|
+
const locale = requestLocale(Astro.url, Astro.locals);
|
|
17
|
+
const rawPage = slug ? await repo.getPageBySlug(slug) : null;
|
|
18
|
+
const page = rawPage ? localizeDoc(rawPage, locale) : null;
|
|
15
19
|
|
|
16
20
|
// Drafts are visible only to a logged-in admin (doubles as draft preview).
|
|
17
21
|
const isAdmin = (await validateSession(Astro.cookies.get(SESSION_COOKIE)?.value, Astro.locals));
|
|
@@ -20,7 +24,8 @@ if (!page || (page.status !== 'published' && !isAdmin)) {
|
|
|
20
24
|
}
|
|
21
25
|
const bodyHtml = page.bodyHtml ? highlightCodeHtml(await expandDynamicBlocks(page.bodyHtml, repo)) : '';
|
|
22
26
|
// ?designPreview=1 (admin-only) renders the staged Design-screen changes
|
|
23
|
-
const
|
|
27
|
+
const baseSettings = await effectiveSettings(await repo.getSettings(), Astro.url, Astro.cookies, Astro.locals);
|
|
28
|
+
const settings = isExtraLocale(locale) ? { ...baseSettings, locale } : baseSettings;
|
|
24
29
|
// Live state must not survive in a cached page after its snapshot expires.
|
|
25
30
|
if (settings.live.mode !== 'off' || page?.blocks.some(block => block.type === 'live') ||
|
|
26
31
|
(page && settings.sectionPages?.[page.id]?.sections.some(section => section.type === 'live'))) {
|
|
@@ -43,7 +48,7 @@ const PageTemplate = (page.template && settings.sectionPages?.[page.id]?.mode !=
|
|
|
43
48
|
</p>
|
|
44
49
|
)}
|
|
45
50
|
{settings.sectionPages?.[page.id]?.mode === 'sections' ? (
|
|
46
|
-
<ThemeSections page={page} settings={settings} />
|
|
51
|
+
<ThemeSections page={page} settings={settings} locale={locale} />
|
|
47
52
|
) : PageTemplate ? (
|
|
48
53
|
<PageTemplate page={page} html={bodyHtml} settings={settings} />
|
|
49
54
|
) : page.bodyHtml ? (
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { roleLabels } from '../../lib/roles';
|
|
3
|
+
// Editor-app sign-in (OAuth 2.0 authorization code + PKCE, RFC 8252). The
|
|
4
|
+
// app opens this page in the system browser; the middleware has already sent
|
|
5
|
+
// signed-out visitors to /admin/login?next=… and back. Approving issues a
|
|
6
|
+
// one-time code (two minutes, single use) bound to this person, the app, its
|
|
7
|
+
// return address and the PKCE challenge, then returns to the app.
|
|
8
|
+
import { getRepo } from '../../lib/repo';
|
|
9
|
+
import { BRAND } from '../../lib/brand';
|
|
10
|
+
import { validateSession, OWNER_ENV, SESSION_COOKIE } from '../../lib/auth';
|
|
11
|
+
import { EDITOR } from '../../lib/editor/config';
|
|
12
|
+
import { checkAuthorize, checkConsentTicket, consentTicket, issueCode, redirectWith } from '../../lib/editor/auth';
|
|
13
|
+
import { bookingsEnabled } from '../../lib/calendar/config';
|
|
14
|
+
import { I18N } from '../../lib/i18n';
|
|
15
|
+
import ThemeScript from '../../components/ThemeScript.astro';
|
|
16
|
+
import '../../styles/admin.css';
|
|
17
|
+
|
|
18
|
+
// never framed (clickjacking the Approve button), never cached; the query
|
|
19
|
+
// (challenge, state) never leaks as a referrer. Not "no-referrer": that
|
|
20
|
+
// makes browsers send Origin: null on the form POST, failing the CSRF check.
|
|
21
|
+
Astro.response.headers.set('X-Frame-Options', 'DENY');
|
|
22
|
+
Astro.response.headers.set('Content-Security-Policy', "frame-ancestors 'none'");
|
|
23
|
+
Astro.response.headers.set('Cache-Control', 'no-store');
|
|
24
|
+
Astro.response.headers.set('Referrer-Policy', 'same-origin');
|
|
25
|
+
if (!EDITOR.enabled) return new Response(null, { status: 404 });
|
|
26
|
+
|
|
27
|
+
const leave = (to: string) => new Response(null, { status: 303, headers: { Location: to, 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' } });
|
|
28
|
+
const repo = await getRepo(Astro.locals);
|
|
29
|
+
const session = Astro.cookies.get(SESSION_COOKIE)?.value ?? '';
|
|
30
|
+
const userId = await validateSession(session, Astro.locals);
|
|
31
|
+
if (!userId) return Astro.redirect(`/admin/login?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`);
|
|
32
|
+
|
|
33
|
+
const check = checkAuthorize(Astro.url.searchParams);
|
|
34
|
+
const iss = Astro.url.origin;
|
|
35
|
+
// untrustworthy return address/app: show the problem here, never redirect
|
|
36
|
+
if (!check.ok && check.redirect) {
|
|
37
|
+
return leave(redirectWith(check.redirectUri, { error: check.error, error_description: check.message, ...(check.state ? { state: check.state } : {}), iss }));
|
|
38
|
+
}
|
|
39
|
+
const params = check.ok ? check.params : null;
|
|
40
|
+
const envOwner = userId === OWNER_ENV;
|
|
41
|
+
const user = envOwner ? null : await repo.getUser(userId);
|
|
42
|
+
const settings = await repo.getSettings();
|
|
43
|
+
const appName = EDITOR.name || params?.clientId || 'An app';
|
|
44
|
+
const switchUrl = `/admin/logout?next=${encodeURIComponent(Astro.url.pathname + Astro.url.search)}`;
|
|
45
|
+
|
|
46
|
+
let error = '';
|
|
47
|
+
if (Astro.request.method === 'POST' && params && user) {
|
|
48
|
+
const form = await Astro.request.formData();
|
|
49
|
+
const origin = Astro.request.headers.get('origin');
|
|
50
|
+
if (origin !== Astro.url.origin || Astro.request.headers.get('sec-fetch-site') === 'cross-site') error = 'This request didn’t come from this page. Try again.';
|
|
51
|
+
else if (!checkConsentTicket(String(form.get('ticket') ?? ''), session, params)) error = 'This sign-in page expired. Start again from the app.';
|
|
52
|
+
else if (form.get('op') === 'cancel') return leave(redirectWith(params.redirectUri, { error: 'access_denied', error_description: 'Sign-in was cancelled.', state: params.state, iss }));
|
|
53
|
+
else if (form.get('op') === 'approve') {
|
|
54
|
+
if (!await repo.email.rateLimit(`editor:approve:${user.id}`, 10, 60_000)) error = 'Too many sign-ins in a minute. Wait a moment and try again.';
|
|
55
|
+
else {
|
|
56
|
+
const version = await repo.email.sessionVersion(user.id);
|
|
57
|
+
if (version === null) error = 'Your account changed. Sign in again.';
|
|
58
|
+
else {
|
|
59
|
+
const code = await issueCode(repo, { id: user.id, sessionVersion: version }, params, String(form.get('device_name') ?? ''));
|
|
60
|
+
return leave(redirectWith(params.redirectUri, { code, state: params.state, iss }));
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const ticket = params && user ? consentTicket(session, params) : '';
|
|
66
|
+
const deviceName = params?.deviceName || 'Phone';
|
|
67
|
+
const returnTo = (() => { if (!params) return ''; try { const u = new URL(params.redirectUri); return u.protocol === 'https:' || u.protocol === 'http:' ? u.host : `${u.protocol}//`; } catch { return ''; } })();
|
|
68
|
+
const owner = user?.role === 'owner';
|
|
69
|
+
const can = [
|
|
70
|
+
'Edit, publish and unpublish posts, pages and events',
|
|
71
|
+
owner ? 'Change section content, including locked sections' : 'Change section text and images (not the layout)',
|
|
72
|
+
'Upload images and files',
|
|
73
|
+
...(I18N.enabled ? ['Write and review translations'] : []),
|
|
74
|
+
...(bookingsEnabled() ? ['Confirm, decline and cancel bookings'] : []),
|
|
75
|
+
];
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
<!doctype html>
|
|
79
|
+
<html lang="en">
|
|
80
|
+
<head>
|
|
81
|
+
<meta charset="utf-8" />
|
|
82
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
|
83
|
+
<ThemeScript />
|
|
84
|
+
<meta name="robots" content="noindex" />
|
|
85
|
+
<title>Sign in to {appName} · {settings.siteTitle}</title>
|
|
86
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
87
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
|
88
|
+
</head>
|
|
89
|
+
<body>
|
|
90
|
+
<main class="login-wrap consent-wrap">
|
|
91
|
+
<div class="card consent-card">
|
|
92
|
+
<div class="consent-site">
|
|
93
|
+
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
|
94
|
+
<rect x="1" y="3" width="22" height="4.4" rx="1.9" fill="currentColor" opacity="0.85" />
|
|
95
|
+
<rect x="1" y="10" width="12.5" height="4.4" rx="1.9" fill="var(--accent)" />
|
|
96
|
+
<rect x="1" y="17" width="17.5" height="4.4" rx="1.9" fill="currentColor" opacity="0.35" />
|
|
97
|
+
</svg>
|
|
98
|
+
<span>{settings.siteTitle || BRAND.name}</span>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
{!params && (
|
|
102
|
+
<div class="consent-body">
|
|
103
|
+
<h1>This app can’t sign in here</h1>
|
|
104
|
+
<p class="consent-lead">{!check.ok && check.message}</p>
|
|
105
|
+
<p class="hint">Close this page and return to the app. If you run this site, check the editor app settings.</p>
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
|
|
109
|
+
{params && envOwner && (
|
|
110
|
+
<div class="consent-body">
|
|
111
|
+
<h1>Use your own account</h1>
|
|
112
|
+
<p class="consent-lead"><b>{appName}</b> signs in as a person, so each phone can be signed out on its own. You’re signed in with the owner password, which has no account to connect.</p>
|
|
113
|
+
<a class="btn primary block" href={switchUrl}>Sign in with an account</a>
|
|
114
|
+
<p class="hint consent-foot">Owners create accounts in Users.</p>
|
|
115
|
+
</div>
|
|
116
|
+
)}
|
|
117
|
+
|
|
118
|
+
{params && user && (
|
|
119
|
+
<form method="post" class="consent-body">
|
|
120
|
+
<h1><span class="consent-app">{appName}</span> wants to sign in</h1>
|
|
121
|
+
<p class="consent-lead">It will use <b>{settings.siteTitle || 'this site'}</b> as you, with your role. You can sign this phone out any time.</p>
|
|
122
|
+
|
|
123
|
+
<div class="consent-who">
|
|
124
|
+
<span class="consent-initial" aria-hidden="true">{(user.name || user.email).trim().charAt(0).toUpperCase()}</span>
|
|
125
|
+
<span class="consent-id">
|
|
126
|
+
<b>{user.name}</b>
|
|
127
|
+
<span>{user.email}</span>
|
|
128
|
+
</span>
|
|
129
|
+
<span class="consent-role"><i class={owner ? 'is-owner' : ''} aria-hidden="true"></i>{user ? roleLabels[user.role] : 'Owner'}</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<p class="consent-label">The app will be able to</p>
|
|
133
|
+
<ul class="consent-can">
|
|
134
|
+
{can.map((line) => <li>{line}</li>)}
|
|
135
|
+
</ul>
|
|
136
|
+
|
|
137
|
+
<label class="field consent-device">
|
|
138
|
+
<span>Name this device</span>
|
|
139
|
+
<input type="text" name="device_name" value={deviceName} maxlength="60" autocomplete="off" required />
|
|
140
|
+
</label>
|
|
141
|
+
|
|
142
|
+
{error && <div class="flash error" role="alert">{error}</div>}
|
|
143
|
+
<input type="hidden" name="ticket" value={ticket} />
|
|
144
|
+
<div class="consent-actions">
|
|
145
|
+
<button class="btn" type="submit" name="op" value="cancel" formnovalidate>Cancel</button>
|
|
146
|
+
<button class="btn primary" type="submit" name="op" value="approve">Approve</button>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
<dl class="consent-meta">
|
|
150
|
+
<div><dt>Returns to</dt><dd><code>{returnTo}</code></dd></div>
|
|
151
|
+
<div><dt>App ID</dt><dd><code>{params.clientId}</code></dd></div>
|
|
152
|
+
</dl>
|
|
153
|
+
<p class="hint consent-foot">Not you? <a href={switchUrl}>Switch account</a></p>
|
|
154
|
+
</form>
|
|
155
|
+
)}
|
|
156
|
+
</div>
|
|
157
|
+
</main>
|
|
158
|
+
</body>
|
|
159
|
+
</html>
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
---
|
|
2
|
+
import { I18N, extraLocales, localeName } from '../../../../lib/i18n';
|
|
3
|
+
import { translationEngine } from '../../../../lib/translate';
|
|
2
4
|
import { getRepo } from '../../../../lib/repo';
|
|
3
5
|
import { renderMarkdown } from '../../../../lib/markdown';
|
|
4
6
|
import { createPreviewToken } from '../../../../lib/preview';
|
|
@@ -37,6 +39,10 @@ const sharePath = `${previewPath}?token=${createPreviewToken('post', post.id)}`;
|
|
|
37
39
|
const unsplashEnabled = Boolean(env('UNSPLASH_ACCESS_KEY'));
|
|
38
40
|
const settings = await repo.getSettings();
|
|
39
41
|
const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
|
|
42
|
+
// other languages this document can be written in (acms({ i18n }))
|
|
43
|
+
const trLanguages = extraLocales().map((code) => ({ code, name: localeName(code, true) }));
|
|
44
|
+
const trMain = { code: I18N.defaultLocale, name: localeName(I18N.defaultLocale || 'en', true) };
|
|
45
|
+
const trDrafts = trLanguages.length > 0 && translationEngine((await repo.getSettings()).ai, Astro.locals) !== null;
|
|
40
46
|
---
|
|
41
47
|
|
|
42
48
|
<!doctype html>
|
|
@@ -54,6 +60,9 @@ const templates = Object.keys(resolveDesign(settings).theme.postTemplates);
|
|
|
54
60
|
<PostEditor
|
|
55
61
|
client:only="react"
|
|
56
62
|
post={editable}
|
|
63
|
+
languages={trLanguages}
|
|
64
|
+
mainLanguage={trMain}
|
|
65
|
+
translationDrafts={trDrafts}
|
|
57
66
|
initialHTML={initialHTML}
|
|
58
67
|
unsplashEnabled={unsplashEnabled}
|
|
59
68
|
previewPath={previewPath}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
import Admin from '../../../layouts/Admin.astro';
|
|
3
|
+
import BookingsHead from '../../../components/calendar/BookingsHead.astro';
|
|
4
|
+
import { eventHolds } from '../../../lib/booking/holds';
|
|
3
5
|
import Modal from '../../../components/Modal.astro';
|
|
4
6
|
import { getRepo } from '../../../lib/repo';
|
|
5
7
|
import type { Booking, BookingStatus } from '../../../lib/schema';
|
|
@@ -73,7 +75,7 @@ if (Astro.request.method === 'POST') {
|
|
|
73
75
|
const allDay = Boolean(form.get('allDay'));
|
|
74
76
|
const start = allDay ? localMidnight(date, tz) : toMs(date, String(form.get('start')));
|
|
75
77
|
const end = allDay ? localMidnight(addDaysYmd(date, 1), tz) : toMs(date, String(form.get('end')));
|
|
76
|
-
if (!r || Number.isNaN(start) || Number.isNaN(end)) throw new BookingError(400, 'Choose a
|
|
78
|
+
if (!r || Number.isNaN(start) || Number.isNaN(end)) throw new BookingError(400, 'Choose a room or person, a date and times.');
|
|
77
79
|
const b = await createBlock(repo, r, start, end, String(form.get('note') || ''));
|
|
78
80
|
if (!b) throw new BookingError(409, 'That time already has a booking — cancel or move it first.');
|
|
79
81
|
return Astro.redirect(keep({ notice: `${r.name} is blocked.` }));
|
|
@@ -85,6 +87,9 @@ if (Astro.request.method === 'POST') {
|
|
|
85
87
|
const start = toMs(String(form.get('date')), String(form.get('time')));
|
|
86
88
|
if (Number.isNaN(start)) throw new BookingError(400, 'Pick a date and time.');
|
|
87
89
|
const len = link.mode === 'range' && link.lengths.length ? Number(form.get('len')) || link.lengths[0] : link.mode === 'day' ? 1440 : link.duration;
|
|
90
|
+
// staff may book over an event's hold, but only on purpose
|
|
91
|
+
const held = (await eventHolds(repo, settings, start, start + len * 60_000, r.id))[0];
|
|
92
|
+
if (held && !form.has('overHold')) throw new BookingError(409, `${r.name} is held for “${held.title}” then. Tick “Book anyway” to add it regardless.`);
|
|
88
93
|
const b = await createResourceBooking(repo, {
|
|
89
94
|
resource: r, link, start, end: start + len * 60_000, source: 'admin', status: 'confirmed',
|
|
90
95
|
booker: { name: String(form.get('name') || ''), email: String(form.get('email') || ''), phone: String(form.get('phone') || '') },
|
|
@@ -155,6 +160,8 @@ const dayCols = resources.filter((r) => !resFilter || r.id === resFilter).map((r
|
|
|
155
160
|
r, items: all.filter((b) => b.resourceId === r.id && active(b) && b.start < dayEnd && b.end > dayStart),
|
|
156
161
|
}));
|
|
157
162
|
const H0 = 7, H1 = 22;
|
|
163
|
+
// rooms held by events that day (lib/booking/holds.ts), drawn under bookings
|
|
164
|
+
const dayHolds = view === 'day' ? await eventHolds(repo, settings, dayStart, dayEnd) : [];
|
|
158
165
|
const pos = (b: Booking) => {
|
|
159
166
|
const s = Math.max(b.start, dayStart), e = Math.min(b.end, dayEnd);
|
|
160
167
|
const top = ((s - dayStart) / 3_600_000 - H0) / (H1 - H0) * 100;
|
|
@@ -164,7 +171,7 @@ const pos = (b: Booking) => {
|
|
|
164
171
|
|
|
165
172
|
const selected = Astro.url.searchParams.get('b') ? await repo.getBooking(Astro.url.searchParams.get('b')!) : null;
|
|
166
173
|
const provider = emailProvider(Astro.locals);
|
|
167
|
-
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', '
|
|
174
|
+
const TABS = [['upcoming', 'Upcoming'], ['unconfirmed', 'Needs approval'], ['past', 'Past'], ['cancelled', 'Cancelled']] as const;
|
|
168
175
|
const statusLabel = (b: Booking) => (b.kind === 'block' ? 'Blocked' : b.status[0].toUpperCase() + b.status.slice(1));
|
|
169
176
|
const whenOf = (b: Booking) => (b.end - b.start >= 86_400_000 - 3_600_000 && ymd(b.start, tz) !== ymd(b.end - 1, tz)
|
|
170
177
|
? `${f.day.format(b.start)} – ${f.day.format(b.end - 1)}`
|
|
@@ -174,21 +181,14 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
174
181
|
---
|
|
175
182
|
|
|
176
183
|
<Admin title="Bookings" section="calendar:bookings" wide={view === 'day'}>
|
|
177
|
-
<
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
<
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
<a class={view === 'day' ? 'on' : ''} href={keep({ view: 'day' })}>Day</a>
|
|
186
|
-
</nav>
|
|
187
|
-
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
188
|
-
<button class="btn" type="button" data-modal="bk-block" disabled={!resources.length}>Block time</button>
|
|
189
|
-
<button class="btn primary" type="button" data-modal="bk-add" disabled={!activeLinks.length}>Add booking</button>
|
|
190
|
-
</div>
|
|
191
|
-
</div>
|
|
184
|
+
<BookingsHead active="requests" inPlace pending={counts.unconfirmed} rooms={resources.length}
|
|
185
|
+
sub={`${counts.unconfirmed ? `${counts.unconfirmed} ${counts.unconfirmed === 1 ? 'needs' : 'need'} approval · ` : ''}${week} in the next 7 days`}>
|
|
186
|
+
<nav class="seg" aria-label="View">
|
|
187
|
+
<a class={view === 'agenda' ? 'on' : ''} href={keep({ view: null })}>Agenda</a>
|
|
188
|
+
<a class={view === 'day' ? 'on' : ''} href={keep({ view: 'day' })}>Day</a>
|
|
189
|
+
</nav>
|
|
190
|
+
<a class="btn" href={keep({ format: 'csv', b: null, notice: null })}>Export</a>
|
|
191
|
+
</BookingsHead>
|
|
192
192
|
{notice && <p class="notice" role="status">{notice}</p>}
|
|
193
193
|
{error && <p class="notice warn" role="alert">{error}</p>}
|
|
194
194
|
{provider === 'none' && <p class="notice"><span>Emails are off — set <code>EMAIL_FROM</code> plus <code>RESEND_API_KEY</code> (or a Cloudflare <code>send_email</code> binding named EMAIL) so guests get confirmations and reminders.</span></p>}
|
|
@@ -196,13 +196,13 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
196
196
|
{resources.length === 0 && !all.length ? (
|
|
197
197
|
<div class="empty-state">
|
|
198
198
|
<b>No bookings yet</b>
|
|
199
|
-
<p>
|
|
200
|
-
<a class="btn primary" href="/admin/calendar/resources?new=1">
|
|
199
|
+
<p>Bookings are times people reserve with a room or person. Add one, share its booking link, and requests land here.</p>
|
|
200
|
+
<a class="btn primary" href="/admin/calendar/resources?new=1">Add a room or person</a>
|
|
201
201
|
</div>
|
|
202
202
|
) : (
|
|
203
203
|
<>
|
|
204
204
|
<div class="bk-kpis kpis">
|
|
205
|
-
<div class="kpi"><small>
|
|
205
|
+
<div class="kpi"><small>Needs approval</small><b>{counts.unconfirmed}</b></div>
|
|
206
206
|
<div class="kpi"><small>Next 7 days</small><b>{week}</b></div>
|
|
207
207
|
<div class="kpi"><small>Today</small><b>{all.filter((b) => active(b) && ymd(b.start, tz) === today).length}</b></div>
|
|
208
208
|
<div class="kpi"><small>Cancelled · 30 days</small><b>{cancelled30}</b></div>
|
|
@@ -229,9 +229,9 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
229
229
|
</div>
|
|
230
230
|
{resources.length > 1 && (
|
|
231
231
|
<details class={`fmenu ${resFilter ? 'is-set' : ''}`}>
|
|
232
|
-
<summary>{resFilter ? resById.get(resFilter)?.name : 'All
|
|
232
|
+
<summary>{resFilter ? resById.get(resFilter)?.name : 'All rooms & people'} <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 9l6 6 6-6" /></svg></summary>
|
|
233
233
|
<div class="fmenu-pop">
|
|
234
|
-
<a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All
|
|
234
|
+
<a class={!resFilter ? 'on' : ''} href={keep({ r: null })}>All rooms & people</a>
|
|
235
235
|
{resources.map((r) => <a class={resFilter === r.id ? 'on' : ''} href={keep({ r: r.id })}>{r.name}</a>)}
|
|
236
236
|
</div>
|
|
237
237
|
</details>
|
|
@@ -247,6 +247,11 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
247
247
|
<div class="bk-day-hours">{Array.from({ length: H1 - H0 }, (_, i) => <span>{f.time.format(dayStart + (H0 + i) * 3_600_000).replace(':00', '')}</span>)}</div>
|
|
248
248
|
{dayCols.map((c) => (
|
|
249
249
|
<div class="bk-day-col">
|
|
250
|
+
{dayHolds.filter((h) => h.resourceId === c.r.id).map((h) => (
|
|
251
|
+
<a class="bk-blk is-hold" style={pos({ start: h.start, end: h.end } as Booking)} href={`/admin/calendar/events/${h.eventId}`} title={`Held for ${h.title}`}>
|
|
252
|
+
<b>Held · {h.title}</b><small>{f.time.format(h.eventStart)} – {f.time.format(h.eventEnd)}{h.start < h.eventStart || h.end > h.eventEnd ? ' · incl. setup/cleanup' : ''}</small>
|
|
253
|
+
</a>
|
|
254
|
+
))}
|
|
250
255
|
{c.items.map((b) => (
|
|
251
256
|
<a class={`bk-blk is-${b.kind === 'block' ? 'block' : b.status}`} style={pos(b)} href={keep({ b: b.id })}>
|
|
252
257
|
<b>{titleOf(b)}</b><small>{whenOf(b)}</small>
|
|
@@ -260,7 +265,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
260
265
|
<>
|
|
261
266
|
{pending.length > 0 && (
|
|
262
267
|
<section class="card bk-group">
|
|
263
|
-
<div class="bk-dayh"><b>Needs
|
|
268
|
+
<div class="bk-dayh"><b>Needs approval</b><span>{pending.length}</span></div>
|
|
264
269
|
{pending.map((b) => (
|
|
265
270
|
<div class={`bk-row${selected?.id === b.id ? ' is-sel' : ''}`}>
|
|
266
271
|
<a class="bk-when" href={keep({ b: b.id })}>{f.day.format(b.start)}<small>{whenOf(b)}</small></a>
|
|
@@ -354,10 +359,10 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
354
359
|
</>
|
|
355
360
|
)}
|
|
356
361
|
|
|
357
|
-
<Modal id="bk-block" title="Block time">
|
|
362
|
+
<Modal id="bk-block" title="Block time" open={Astro.url.searchParams.has('block') && resources.length > 0}>
|
|
358
363
|
<form method="post">
|
|
359
364
|
<input type="hidden" name="op" value="block" />
|
|
360
|
-
<label class="field"><span>
|
|
365
|
+
<label class="field"><span>Room or person</span><select name="resourceId">{resources.map((r) => <option value={r.id} selected={r.id === resFilter}>{r.name}</option>)}</select></label>
|
|
361
366
|
<div class="field-row three">
|
|
362
367
|
<label class="field"><span>Date</span><input type="date" name="date" value={day} required /></label>
|
|
363
368
|
<label class="field"><span>From</span><input type="time" name="start" value="09:00" /></label>
|
|
@@ -369,7 +374,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
369
374
|
</form>
|
|
370
375
|
</Modal>
|
|
371
376
|
|
|
372
|
-
<Modal id="bk-add" title="Add booking">
|
|
377
|
+
<Modal id="bk-add" title="Add booking" open={Astro.url.searchParams.has('add') && activeLinks.length > 0}>
|
|
373
378
|
<form method="post">
|
|
374
379
|
<input type="hidden" name="op" value="add" />
|
|
375
380
|
<label class="field"><span>Booking link</span><select name="linkId">{activeLinks.map((l) => <option value={l.id}>{resById.get(l.resourceId)?.name} · {l.name}</option>)}</select></label>
|
|
@@ -383,6 +388,7 @@ const activeLinks = links.filter((l) => l.active && resById.has(l.resourceId));
|
|
|
383
388
|
<label class="field"><span>Email</span><input type="email" name="email" required /></label>
|
|
384
389
|
</div>
|
|
385
390
|
<label class="field"><span>Phone <em class="hint">optional</em></span><input type="tel" name="phone" /></label>
|
|
391
|
+
<label class="check-row"><input type="checkbox" name="overHold" /> Book anyway if an event is holding the room</label>
|
|
386
392
|
<div class="modal-foot"><button class="btn ghost" type="button" data-close>Cancel</button><button class="btn primary" type="submit">Add and confirm</button></div>
|
|
387
393
|
<p class="hint modal-note">Staff bookings skip notice windows but can never overlap another booking.</p>
|
|
388
394
|
</form>
|