@riverbankcms/sdk 0.136.0 → 0.137.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (160) hide show
  1. package/CHANGELOG.md +101 -0
  2. package/dist/{PreviewEditorSidebar-BU5ZlhQR.mjs → PreviewEditorSidebar-Bn6cONPa.mjs} +8 -8
  3. package/dist/{PreviewEditorUI-9XkHL-bC.mjs → PreviewEditorUI-LAwXZPSx.mjs} +3 -3
  4. package/dist/{PreviewQueryProvider-wbEpXryy.mjs → PreviewQueryProvider-ztQUdsNo.mjs} +1 -1
  5. package/dist/{SdkPreviewModeRuntime-CnWmj-2B.mjs → SdkPreviewModeRuntime-BbbGsI_v.mjs} +596 -164
  6. package/dist/{SiteChromeCustomizeContext-B6d5zkog.mjs → SiteChromeCustomizeContext-BbkNSt5a.mjs} +2888 -1404
  7. package/dist/_dts/ai/src/contracts/proposals.d.ts +12 -298
  8. package/dist/_dts/ai/src/contracts/siteStyleProposalSchemas.d.ts +21 -14
  9. package/dist/_dts/ai/src/designer/themePatch.d.ts +5 -10
  10. package/dist/_dts/api/src/accessAdmin.d.ts +1 -9
  11. package/dist/_dts/api/src/accountApi.d.ts +49 -2
  12. package/dist/_dts/api/src/contentRuntime.d.ts +1 -4
  13. package/dist/_dts/api/src/domainConnection.d.ts +41 -0
  14. package/dist/_dts/api/src/endpoints.d.ts +194 -181
  15. package/dist/_dts/api/src/index.d.ts +4 -3
  16. package/dist/_dts/api/src/media.d.ts +19 -2
  17. package/dist/_dts/api/src/sdkContracts.d.ts +2 -15
  18. package/dist/_dts/api/src/sdkMediaContracts.d.ts +40 -0
  19. package/dist/_dts/api/src/sdkMediaEndpointDefinitions.d.ts +61 -0
  20. package/dist/_dts/api/src/sdkThemeWire.d.ts +15 -4
  21. package/dist/_dts/api/src/siteInfrastructure.d.ts +17 -23
  22. package/dist/_dts/api/src/siteManagementEndpoints.d.ts +7 -21
  23. package/dist/_dts/api/src/sitePlatformEndpoints.d.ts +27 -3
  24. package/dist/_dts/api/src/siteRuntimeEndpoints.d.ts +37 -17
  25. package/dist/_dts/api/src/types.d.ts +1 -1
  26. package/dist/_dts/blocks/src/PageRenderer.d.ts +2 -2
  27. package/dist/_dts/blocks/src/index.d.ts +1 -1
  28. package/dist/_dts/blocks/src/pageRenderAdmission.d.ts +9 -0
  29. package/dist/_dts/blocks/src/pageRendererBlockSupport.d.ts +75 -0
  30. package/dist/_dts/blocks/src/pageRendererBoundaries.d.ts +2 -0
  31. package/dist/_dts/blocks/src/server.d.ts +1 -1
  32. package/dist/_dts/blocks/src/system/blocks/event-registration.d.ts +1 -1
  33. package/dist/_dts/blocks/src/system/runtime/api/client.d.ts +2 -1
  34. package/dist/_dts/blocks/src/system/runtime/hooks/useCalendarEvents.d.ts +3 -3
  35. package/dist/_dts/blocks/src/system/runtime/hooks/useCalendarEvents.state.d.ts +34 -22
  36. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/ErrorStep.d.ts +3 -5
  37. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/event-registration.headless.contract.d.ts +2 -0
  38. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/event-registration.headless.planning.d.ts +1 -1
  39. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/hooks.d.ts +2 -0
  40. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/registrationErrorPresentation.d.ts +5 -0
  41. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/registrationErrorRecovery.d.ts +25 -0
  42. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/registrationWizardContext.d.ts +4 -0
  43. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/useEventRegistrationStripeReturn.d.ts +7 -3
  44. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/useEventRegistrationSubmit.d.ts +2 -0
  45. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/useEventRegistrationWizard.d.ts +5 -2
  46. package/dist/_dts/blocks/src/system/runtime/nodes/event-registration/useWaitlistClaimRestoration.d.ts +2 -0
  47. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/capacity.d.ts +164 -0
  48. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/cta.d.ts +29 -0
  49. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/dates.d.ts +47 -0
  50. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/index.d.ts +14 -0
  51. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/people.d.ts +6 -0
  52. package/dist/_dts/blocks/src/system/runtime/nodes/events/domain/pricing.d.ts +31 -0
  53. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/OfferingTitle.d.ts +11 -0
  54. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/WeekAgendaView.d.ts +1 -1
  55. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/WeekBoardView.d.ts +1 -1
  56. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/index.d.ts +3 -1
  57. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/listingLayout.d.ts +23 -0
  58. package/dist/_dts/commerce/src/purchase/event-registration-errors.d.ts +57 -0
  59. package/dist/_dts/commerce/src/purchase/index.d.ts +1 -0
  60. package/dist/_dts/db/src/generated/supabase/database.types.d.ts +8 -40
  61. package/dist/_dts/db/src/index.d.ts +1 -0
  62. package/dist/_dts/db/src/utils/postgrestFilter.d.ts +2 -0
  63. package/dist/_dts/media-core/src/bulkDeletePlanning.d.ts +4 -0
  64. package/dist/_dts/media-core/src/fileDomain.d.ts +3 -0
  65. package/dist/_dts/preview-next/src/client/preview/PreviewShell.d.ts +1 -0
  66. package/dist/_dts/preview-next/src/client/preview/PreviewShellLayout.d.ts +1 -0
  67. package/dist/_dts/preview-next/src/client/preview/SiteChromeCustomizeContext.d.ts +3 -2
  68. package/dist/_dts/preview-next/src/client/preview/siteChromeBrandPreferences.d.ts +1 -1
  69. package/dist/_dts/preview-next/src/client/preview/siteChromeQuickControlHistoryState.d.ts +2 -0
  70. package/dist/_dts/preview-next/src/client/preview/siteChromeQuickControlSaveRequest.d.ts +51 -0
  71. package/dist/_dts/preview-next/src/client/preview/siteChromeSavePlan.d.ts +24 -2
  72. package/dist/_dts/preview-next/src/client/preview/siteStylePreviewPlanner.d.ts +23 -1
  73. package/dist/_dts/preview-next/src/client/preview/usePersistedHeaderPreferences.d.ts +8 -0
  74. package/dist/_dts/sdk/src/public-api/contracts.d.ts +2 -1
  75. package/dist/_dts/sdk/src/version.d.ts +1 -1
  76. package/dist/_dts/theme-core/src/generated/containerResponsiveThemeCss.d.ts +1 -1
  77. package/dist/_dts/theme-core/src/gradients/types.d.ts +5 -0
  78. package/dist/_dts/theme-core/src/header/interactionPalette.d.ts +25 -2
  79. package/dist/_dts/theme-core/src/mock-themes/all.d.ts +3 -3
  80. package/dist/_dts/theme-core/src/schema.d.ts +12 -11
  81. package/dist/_dts/theme-core/src/site-styles/compilationPlan.d.ts +43 -3
  82. package/dist/_dts/theme-core/src/site-styles/curatedSiteStyles.d.ts +10 -0
  83. package/dist/_dts/theme-core/src/site-styles/headerBrandPresentation.d.ts +63 -0
  84. package/dist/_dts/theme-core/src/site-styles/headerBytesComparison.d.ts +30 -0
  85. package/dist/_dts/theme-core/src/site-styles/headerInteractionPalette.d.ts +50 -1
  86. package/dist/_dts/theme-core/src/site-styles/headerLayoutCatalog.d.ts +48 -9
  87. package/dist/_dts/theme-core/src/site-styles/headerPreferences.d.ts +162 -0
  88. package/dist/_dts/theme-core/src/site-styles/headerPreferencesPersistence.d.ts +73 -0
  89. package/dist/_dts/theme-core/src/site-styles/headerRecompile.d.ts +118 -0
  90. package/dist/_dts/theme-core/src/site-styles/headerStyleCompiler.d.ts +10 -1
  91. package/dist/_dts/theme-core/src/site-styles/headerStyleDefaults.d.ts +1 -1
  92. package/dist/_dts/theme-core/src/site-styles/headerStyleRecipe.d.ts +7 -0
  93. package/dist/_dts/theme-core/src/site-styles/index.d.ts +5 -0
  94. package/dist/_dts/theme-core/src/site-styles/previewCompiler.d.ts +1 -1
  95. package/dist/_dts/theme-core/src/site-styles/siteChromeBackgrounds.d.ts +20 -1
  96. package/dist/_dts/theme-core/src/site-styles/siteChromeNavPreferences.d.ts +43 -2
  97. package/dist/client/{BookingConsentAgreement-DCch21a6.mjs → BookingConsentAgreement--_fUCZe-.mjs} +2 -2
  98. package/dist/client/{DiscountCodeDisclosure-DIj1T59K.mjs → DiscountCodeDisclosure-BMipSoDJ.mjs} +1 -1
  99. package/dist/client/{EventCombined.client-BWVNlXTx.mjs → EventCombined.client-CwJOxn7A.mjs} +154 -178
  100. package/dist/client/{EventListing.client-DVFCDe3i.mjs → EventListing.client-eVwiylPW.mjs} +2 -2
  101. package/dist/client/{LeafletStaticMap-BY5mIHw9.mjs → LeafletStaticMap-IKenna5u.mjs} +1 -1
  102. package/dist/client/{accordion-CK29E8rG.mjs → accordion-MGAO3PuE.mjs} +1 -1
  103. package/dist/client/{bookingForm-DFvnet-N.mjs → bookingForm-CudlQPUu.mjs} +5 -5
  104. package/dist/client/bookings.mjs +418 -79
  105. package/dist/client/capacity-BW00PO7s.mjs +271 -0
  106. package/dist/client/{carousel-BVU5NQlb.mjs → carousel-BrbGJ2Jl.mjs} +1 -1
  107. package/dist/client/{cart-BNItcZVa.mjs → cart-Bq5eU0xZ.mjs} +3 -3
  108. package/dist/client/{checkout-Bu6iCdkP.mjs → checkout-BAFVRdrK.mjs} +3 -3
  109. package/dist/client/client.mjs +2919 -1918
  110. package/dist/client/{context-BImarOAq.mjs → context-DCkiHVdD.mjs} +8 -5
  111. package/dist/client/dates-2RPXHQy4.mjs +264 -0
  112. package/dist/client/{eventCalendar-DS2h-UXD.mjs → eventCalendar-B0O-hPF7.mjs} +10 -17
  113. package/dist/client/{eventCombined-Drt4Oq9p.mjs → eventCombined-DCkDaoDI.mjs} +3 -3
  114. package/dist/client/{eventDetails-Bl2vWgTX.mjs → eventDetails-D8GG2dnD.mjs} +5 -3
  115. package/dist/client/{eventListing-CS7PHY7v.mjs → eventListing-3ssV_bxz.mjs} +3 -3
  116. package/dist/client/{eventRegistration-D-j8eNy2.mjs → eventRegistration-bPQo3kVw.mjs} +562 -309
  117. package/dist/client/{form-RMpoLTyr.mjs → form-qMv8RDkk.mjs} +2 -2
  118. package/dist/client/{headerSection-BYE3Lb3k.mjs → headerSection-CH0Sc5hX.mjs} +2 -2
  119. package/dist/client/hooks.mjs +289 -32
  120. package/dist/client/{locationMap-BxSr7Kkh.mjs → locationMap-0_xlBnNI.mjs} +2 -2
  121. package/dist/client/{newsletterForm-DerbXwFC.mjs → newsletterForm-NUW7m9RI.mjs} +2 -2
  122. package/dist/client/{productDetail-4SZCLhAf.mjs → productDetail-FBEOM7jW.mjs} +3 -3
  123. package/dist/client/{productList-D7AP4GNq.mjs → productList-R0OSGhnn.mjs} +3 -3
  124. package/dist/client/{propsDecoder-pzBeCh14.mjs → propsDecoder-pYvx_Srl.mjs} +88 -380
  125. package/dist/client/rendering/client.mjs +2616 -1637
  126. package/dist/client/rendering/islands.mjs +17 -17
  127. package/dist/client/rendering.mjs +2710 -1702
  128. package/dist/client/{scrollToBlock-CduAKf8T.mjs → scrollToBlock-ByQv96M2.mjs} +1 -1
  129. package/dist/client/{shop-Cp5p_kGe.mjs → shop-RUG9DmRE.mjs} +6 -6
  130. package/dist/client/{shop-commerce-DicXYaYX.mjs → shop-commerce-B76Jz23B.mjs} +5 -5
  131. package/dist/client/{shop-commerce-store-CGWnpY4K.mjs → shop-commerce-store-QuQNdDsD.mjs} +1 -1
  132. package/dist/client/{shopShared-BDf9ljkm.mjs → shopShared-vXU1omwJ.mjs} +3 -3
  133. package/dist/client/{useEventFilters-DeeyGYuk.mjs → useEventFilters-ByuU6HLA.mjs} +33 -18
  134. package/dist/preview-next/before-render.mjs +79 -51
  135. package/dist/preview-next/client/runtime.mjs +4 -4
  136. package/dist/preview-next/keepAlive.mjs +79 -51
  137. package/dist/preview-next/middleware.mjs +79 -51
  138. package/dist/{previewAuthStore-DbqBol0V.mjs → previewAuthStore-L_cKW8Sb.mjs} +79 -51
  139. package/dist/{sdk-runtime-19MO27F0.mjs → sdk-runtime-sUMr5d70.mjs} +2 -2
  140. package/dist/{separator-DaPLtJ1f.mjs → separator-BIHkie5i.mjs} +1 -1
  141. package/dist/server/components.mjs +2077 -1274
  142. package/dist/server/config-validation.mjs +289 -32
  143. package/dist/server/config.mjs +289 -32
  144. package/dist/server/data.mjs +289 -32
  145. package/dist/server/index.mjs +80 -52
  146. package/dist/server/next.mjs +1822 -1111
  147. package/dist/server/page-converter.mjs +167 -31
  148. package/dist/server/prebuild.mjs +1 -1
  149. package/dist/server/rendering/server.mjs +1988 -1286
  150. package/dist/server/rendering.mjs +1943 -1182
  151. package/dist/server/routing.mjs +351 -86
  152. package/dist/server/server.mjs +293 -128
  153. package/dist/server/theme-bridge.mjs +52 -8
  154. package/dist/server/theme.mjs +27 -8
  155. package/dist/styles/index.css +15 -0
  156. package/package.json +1 -1
  157. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/eventCapacity.d.ts +0 -48
  158. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/pricing.d.ts +0 -17
  159. package/dist/_dts/blocks/src/system/runtime/nodes/events/shared/utils.d.ts +0 -100
  160. /package/dist/_dts/blocks/src/system/runtime/nodes/events/{shared → domain}/destination.d.ts +0 -0
@@ -2179,6 +2179,83 @@ const SDK_ENDPOINT_DEFINITIONS = defineEndpointDefinitions({
2179
2179
  errors: ["validation:invalid_input", "server:internal_error"]
2180
2180
  }
2181
2181
  });
2182
+ //#endregion
2183
+ //#region ../api/src/sdkMediaEndpointDefinitions.ts
2184
+ const SDK_MEDIA_ENDPOINT_DEFINITIONS = {
2185
+ sdkMediaExists: {
2186
+ path: "/sdk/{siteId}/media/exists",
2187
+ method: "POST",
2188
+ errors: ["validation:invalid_input"],
2189
+ auth: "service",
2190
+ responseKind: "json"
2191
+ },
2192
+ sdkMediaFindByChecksum: {
2193
+ path: "/sdk/{siteId}/media/find-by-checksum",
2194
+ method: "POST",
2195
+ errors: ["server:internal_error"],
2196
+ auth: "service",
2197
+ responseKind: "json"
2198
+ },
2199
+ sdkMediaResolve: {
2200
+ path: "/sdk/{siteId}/media/resolve",
2201
+ method: "POST",
2202
+ errors: ["server:internal_error"],
2203
+ auth: "service",
2204
+ responseKind: "json"
2205
+ },
2206
+ sdkMediaList: {
2207
+ path: "/sdk/{siteId}/media",
2208
+ method: "GET",
2209
+ auth: "service",
2210
+ responseKind: "json"
2211
+ },
2212
+ sdkMediaDownload: {
2213
+ path: "/sdk/{siteId}/media/{identifier}/download",
2214
+ method: "GET",
2215
+ auth: "service",
2216
+ responseKind: "json"
2217
+ },
2218
+ sdkMediaDeletePreview: {
2219
+ path: "/sdk/{siteId}/media/{identifier}/delete-preview",
2220
+ method: "POST",
2221
+ auth: "service",
2222
+ responseKind: "json"
2223
+ },
2224
+ sdkMediaDeleteApply: {
2225
+ path: "/sdk/{siteId}/media/{identifier}",
2226
+ method: "DELETE",
2227
+ auth: "service",
2228
+ responseKind: "json"
2229
+ },
2230
+ sdkMediaUploadUrl: {
2231
+ path: "/sdk/{siteId}/media/upload-url",
2232
+ method: "POST",
2233
+ errors: [
2234
+ "resource:not_found",
2235
+ "validation:invalid_input",
2236
+ "resource:conflict",
2237
+ "validation:payload_too_large",
2238
+ "billing:plan_limit_exceeded",
2239
+ "server:internal_error"
2240
+ ],
2241
+ auth: "service",
2242
+ responseKind: "json"
2243
+ },
2244
+ sdkMediaUploadFinalize: {
2245
+ path: "/sdk/{siteId}/media/upload",
2246
+ method: "POST",
2247
+ errors: [
2248
+ "resource:not_found",
2249
+ "validation:invalid_input",
2250
+ "resource:conflict",
2251
+ "validation:payload_too_large",
2252
+ "billing:plan_limit_exceeded",
2253
+ "server:internal_error"
2254
+ ],
2255
+ auth: "service",
2256
+ responseKind: "json"
2257
+ }
2258
+ };
2182
2259
  const API_ENDPOINTS$1 = {
2183
2260
  ...ACCOUNT_ENDPOINT_DEFINITIONS,
2184
2261
  ...PUBLIC_EVENT_ENDPOINT_DEFINITIONS,
@@ -4319,26 +4396,6 @@ const API_ENDPOINTS$1 = {
4319
4396
  auth: "public",
4320
4397
  responseKind: "json"
4321
4398
  },
4322
- createTheme: {
4323
- path: "/ai/chat/create-theme",
4324
- method: "POST",
4325
- revalidate: 60,
4326
- tags: ["theme", "ai-theme"],
4327
- auth: "user",
4328
- responseKind: "json"
4329
- },
4330
- generateThemes: {
4331
- path: "/theme/generate",
4332
- method: "POST",
4333
- errors: [
4334
- "validation:invalid_input",
4335
- "resource:not_found",
4336
- "server:internal_error"
4337
- ],
4338
- tags: ["theme"],
4339
- auth: "user",
4340
- responseKind: "json"
4341
- },
4342
4399
  extractThemeFromInspiration: {
4343
4400
  path: "/theme/extract-from-inspiration",
4344
4401
  method: "POST",
@@ -4512,7 +4569,7 @@ const API_ENDPOINTS$1 = {
4512
4569
  method: "DELETE",
4513
4570
  tags: ["media", "media-{assetId}"],
4514
4571
  auth: "user",
4515
- responseKind: "void"
4572
+ responseKind: "json"
4516
4573
  },
4517
4574
  mediaBulkDelete: {
4518
4575
  path: "/media/bulk-delete",
@@ -7098,42 +7155,13 @@ const API_ENDPOINTS$1 = {
7098
7155
  auth: "user",
7099
7156
  responseKind: "json"
7100
7157
  },
7158
+ ...SDK_MEDIA_ENDPOINT_DEFINITIONS,
7101
7159
  sdkIdentifiersBackfill: {
7102
7160
  path: "/sdk/{siteId}/identifiers/backfill",
7103
7161
  method: "POST",
7104
7162
  auth: "service",
7105
7163
  responseKind: "json"
7106
7164
  },
7107
- sdkMediaExists: {
7108
- path: "/sdk/{siteId}/media/exists",
7109
- method: "POST",
7110
- auth: "service",
7111
- responseKind: "json"
7112
- },
7113
- sdkMediaFindByChecksum: {
7114
- path: "/sdk/{siteId}/media/find-by-checksum",
7115
- method: "POST",
7116
- auth: "service",
7117
- responseKind: "json"
7118
- },
7119
- sdkMediaResolve: {
7120
- path: "/sdk/{siteId}/media/resolve",
7121
- method: "POST",
7122
- auth: "service",
7123
- responseKind: "json"
7124
- },
7125
- sdkMediaUploadUrl: {
7126
- path: "/sdk/{siteId}/media/upload-url",
7127
- method: "POST",
7128
- auth: "service",
7129
- responseKind: "json"
7130
- },
7131
- sdkMediaUploadFinalize: {
7132
- path: "/sdk/{siteId}/media/upload",
7133
- method: "POST",
7134
- auth: "service",
7135
- responseKind: "json"
7136
- },
7137
7165
  sdkUpdateWorkflowRunStatus: {
7138
7166
  path: "/sdk/{siteId}/workflows/runs/{runId}/status",
7139
7167
  method: "POST",
@@ -16987,6 +17015,11 @@ const gradientConfigSchema = z$1.object({
16987
17015
  position: z$1.string().optional(),
16988
17016
  stops: z$1.array(gradientStopSchema).min(2)
16989
17017
  });
17018
+ const gradientsSchema = z$1.object({
17019
+ button: z$1.string().min(1).nullable().optional(),
17020
+ hero: z$1.string().min(1).nullable().optional(),
17021
+ background: z$1.string().min(1).nullable().optional()
17022
+ });
16990
17023
  //#endregion
16991
17024
  //#region ../theme-core/src/interactive/baseSchema.ts
16992
17025
  /**
@@ -17987,11 +18020,30 @@ const headerContainerSchema = z$1.object({
17987
18020
  tint: z$1.string().nullable().optional(),
17988
18021
  opacity: z$1.number().min(0).max(1).default(.12)
17989
18022
  }).optional();
17990
- const headerInteractionPaletteSchema = z$1.enum([
18023
+ //#endregion
18024
+ //#region ../theme-core/src/header/interactionPalette.ts
18025
+ /**
18026
+ * The palettes that fill the nav link with a colour on hover and focus. Each
18027
+ * one names a background/foreground token pair.
18028
+ */
18029
+ const headerInteractionPillPaletteValues = [
17991
18030
  "primary",
17992
18031
  "secondary",
17993
18032
  "accent"
17994
- ]);
18033
+ ];
18034
+ /**
18035
+ * "No pill": the nav link keeps whatever hover treatment its recipe compiled,
18036
+ * and the palette step writes nothing at all (#1318 Phase 4c decision 2).
18037
+ *
18038
+ * It exists because "a colour change on hover, no filled background" was not
18039
+ * expressible in the vocabulary, and it is what
18040
+ * `anna-raven-therapy-and-yoga` and `kirtan-lucy` have rendered since before
18041
+ * the palette existed. Without it those two rows can only be classified as a
18042
+ * conscious visual change.
18043
+ */
18044
+ const headerInteractionQuietPalette = "quiet";
18045
+ const headerInteractionPaletteValues = [...headerInteractionPillPaletteValues, headerInteractionQuietPalette];
18046
+ const headerInteractionPaletteSchema = z$1.enum(headerInteractionPaletteValues);
17995
18047
  //#endregion
17996
18048
  //#region ../theme-core/src/boxBackgroundSchema.ts
17997
18049
  const boxBackgroundOverlaySchema = z$1.preprocess(normalizeAuthoringOpacity, z$1.discriminatedUnion("type", [
@@ -18373,11 +18425,12 @@ const inputStyle = z$1.object({
18373
18425
  ]),
18374
18426
  label: z$1.enum(["inside", "above"])
18375
18427
  });
18376
- const headerPositioning = z$1.enum([
18428
+ const headerPositioningValues = [
18377
18429
  "static",
18378
18430
  "sticky",
18379
18431
  "fixed"
18380
- ]);
18432
+ ];
18433
+ const headerPositioning = z$1.enum(headerPositioningValues);
18381
18434
  const headerNavStyle = z$1.enum([
18382
18435
  "minimal",
18383
18436
  "underline",
@@ -18512,7 +18565,7 @@ const headerSchema = z$1.object({
18512
18565
  variant: headerVariantWithLegacyAliasSchema,
18513
18566
  positioning: headerPositioning,
18514
18567
  shrinkOnScroll: z$1.boolean(),
18515
- maxWidth: headerMaxWidth,
18568
+ maxWidth: headerMaxWidth.default("container"),
18516
18569
  scrollTreatment: headerScrollTreatmentSchema,
18517
18570
  background: headerBackgroundSchema.default({
18518
18571
  type: "color",
@@ -18711,11 +18764,6 @@ const bodyTextSchema = z$1.object({ intro: z$1.object({
18711
18764
  paragraphSize: bodyTextIntroTypographySizeSchema.optional(),
18712
18765
  headingSize: bodyTextIntroTypographySizeSchema.optional()
18713
18766
  }).optional() }).optional();
18714
- const gradientsSchema = z$1.object({
18715
- button: z$1.string().min(1).nullable().optional(),
18716
- hero: z$1.string().min(1).nullable().optional(),
18717
- background: z$1.string().min(1).nullable().optional()
18718
- });
18719
18767
  /**
18720
18768
  * Testimonial quote-mark treatment, owned by the site style (analogous to the
18721
18769
  * accordion icon). `style` is the coarse selector; the optional fields fine-tune
@@ -21759,12 +21807,12 @@ function allGroupChildPlans(plan) {
21759
21807
  }
21760
21808
  function selectedGroupChildPlans(plan, value) {
21761
21809
  if (plan.childSelection.kind === "all") return plan.childSelection.fields;
21762
- if (!isPlainObject$6(value)) return [];
21810
+ if (!isPlainObject$7(value)) return [];
21763
21811
  const selectedType = value[plan.childSelection.activeTabField];
21764
21812
  return plan.childSelection.tabs.find((tab) => tab.typeId === selectedType)?.fields ?? [];
21765
21813
  }
21766
21814
  function canonicalizeObjectForWrite(plans, value) {
21767
- if (!isPlainObject$6(value)) return value;
21815
+ if (!isPlainObject$7(value)) return value;
21768
21816
  const canonical = { ...value };
21769
21817
  for (const plan of plans) {
21770
21818
  if (!(plan.fieldId in canonical)) continue;
@@ -21788,7 +21836,7 @@ function canonicalizeFieldValue(plan, value) {
21788
21836
  }
21789
21837
  }
21790
21838
  function canonicalizeMedia(value) {
21791
- if (!isPlainObject$6(value)) return value;
21839
+ if (!isPlainObject$7(value)) return value;
21792
21840
  const canonical = { ...value };
21793
21841
  if (mediaIdentifierSchema.safeParse(canonical.identifier).success) deleteEnvSpecificKeys(canonical);
21794
21842
  return canonical;
@@ -21796,13 +21844,13 @@ function canonicalizeMedia(value) {
21796
21844
  function canonicalizeRepeater(plan, value) {
21797
21845
  if (!isUnknownArray$3(value)) return value;
21798
21846
  return value.map((item) => {
21799
- if (!isPlainObject$6(item)) return item;
21847
+ if (!isPlainObject$7(item)) return item;
21800
21848
  const childPlans = plan.repeatedItemVariants ? plan.repeatedItemVariants.find((variant) => variant.typeId === item._type)?.fields : plan.repeatedItemPlan;
21801
21849
  return childPlans ? canonicalizeObjectForWrite(childPlans, item) : item;
21802
21850
  });
21803
21851
  }
21804
21852
  function canonicalizeDiscriminatedGroup(plan, value) {
21805
- if (!isPlainObject$6(value) || plan.childSelection.kind !== "discriminatedTabs") return value;
21853
+ if (!isPlainObject$7(value) || plan.childSelection.kind !== "discriminatedTabs") return value;
21806
21854
  const selection = plan.childSelection;
21807
21855
  const selectedPlans = selectedGroupChildPlans(plan, value);
21808
21856
  const selectedIds = rootFieldIds(selectedPlans);
@@ -21817,10 +21865,10 @@ function rootFieldIds(plans) {
21817
21865
  }
21818
21866
  function omitUndefined(value) {
21819
21867
  if (isUnknownArray$3(value)) return value.map(omitUndefined);
21820
- if (!isPlainObject$6(value)) return value;
21868
+ if (!isPlainObject$7(value)) return value;
21821
21869
  return Object.fromEntries(Object.entries(value).filter(([, field]) => field !== void 0).map(([key, field]) => [key, omitUndefined(field)]));
21822
21870
  }
21823
- function isPlainObject$6(value) {
21871
+ function isPlainObject$7(value) {
21824
21872
  return typeof value === "object" && value !== null && !Array.isArray(value);
21825
21873
  }
21826
21874
  function isUnknownArray$3(value) {
@@ -22216,7 +22264,7 @@ function validateNormalizedFieldValue(plan, value, ctx) {
22216
22264
  case "select": return validateSelectPlan(plan, value);
22217
22265
  case "richText": return isValidRichTextDoc(value) ? [] : [issue(plan, "invalidRichText")];
22218
22266
  case "inlineText": return validateInlineTextPlan(plan, value);
22219
- case "media": return isPlainObject$5(value) && mediaIdentifierSchema.safeParse(value.identifier).success && (value.type === void 0 || typeof value.type === "string" && plan.allowedMediaTypes.includes(value.type)) ? [] : [issue(plan, "invalidMedia")];
22267
+ case "media": return isPlainObject$6(value) && mediaIdentifierSchema.safeParse(value.identifier).success && (value.type === void 0 || typeof value.type === "string" && plan.allowedMediaTypes.includes(value.type)) ? [] : [issue(plan, "invalidMedia")];
22220
22268
  case "link": return linkSchema.safeParse(value).success ? [] : [issue(plan, "invalidLink")];
22221
22269
  case "repeater": return validateRepeaterPlan(plan, value, ctx);
22222
22270
  case "group": return validateGroupPlan(plan, value, ctx);
@@ -22309,7 +22357,7 @@ function validateRepeaterPlan(plan, value, ctx) {
22309
22357
  }), ...value.flatMap((item, index) => validateRepeaterItem(plan, item, index, ctx))];
22310
22358
  }
22311
22359
  function validateRepeaterItem(plan, item, index, ctx) {
22312
- if (!isPlainObject$5(item)) return [{
22360
+ if (!isPlainObject$6(item)) return [{
22313
22361
  kind: "wrongType",
22314
22362
  path: [...plan.path, index],
22315
22363
  fieldId: plan.fieldId,
@@ -22335,7 +22383,7 @@ function validateRepeaterItem(plan, item, index, ctx) {
22335
22383
  });
22336
22384
  }
22337
22385
  function validateGroupPlan(plan, value, ctx) {
22338
- if (!isPlainObject$5(value)) return [issue(plan, "wrongType", { expected: "an object" })];
22386
+ if (!isPlainObject$6(value)) return [issue(plan, "wrongType", { expected: "an object" })];
22339
22387
  if (plan.childSelection.kind === "discriminatedTabs") {
22340
22388
  const selectedType = value[plan.childSelection.activeTabField];
22341
22389
  if (selectedType !== void 0 && selectedType !== null && !plan.childSelection.tabs.some((tab) => tab.typeId === selectedType)) return [{
@@ -22638,7 +22686,7 @@ function isEmptyValue$1(value) {
22638
22686
  if (isEmptyRichTextDoc(value)) return true;
22639
22687
  return false;
22640
22688
  }
22641
- function isPlainObject$5(value) {
22689
+ function isPlainObject$6(value) {
22642
22690
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
22643
22691
  }
22644
22692
  function isUnknownArray$2(value) {
@@ -22648,7 +22696,7 @@ function isValidRichTextDoc(value) {
22648
22696
  return isRichTextDoc(value);
22649
22697
  }
22650
22698
  function isEmptyRichTextDoc(value) {
22651
- if (!isPlainObject$5(value)) return false;
22699
+ if (!isPlainObject$6(value)) return false;
22652
22700
  if (value.type !== "doc") return false;
22653
22701
  const content = value.content;
22654
22702
  if (!Array.isArray(content) || content.length === 0) return true;
@@ -22700,26 +22748,6 @@ function compileManifestContentContract(manifestOrFields) {
22700
22748
  findNonPortableMediaPaths: (content) => findNonPortableMediaPaths(plans, content)
22701
22749
  }) });
22702
22750
  }
22703
- /** Strictly admits already-persisted content without repairing it. */
22704
- function admitStoredManifestContent(contract, content) {
22705
- const state = contract[compiledManifestContentContract];
22706
- const nonPortablePaths = state.findNonPortableMediaPaths(content);
22707
- if (nonPortablePaths.length > 0) return {
22708
- ok: false,
22709
- error: {
22710
- kind: "non_portable_media",
22711
- paths: nonPortablePaths
22712
- }
22713
- };
22714
- const parsed = state.strictStoredSchema.safeParse(content);
22715
- return parsed.success ? {
22716
- ok: true,
22717
- value: content
22718
- } : {
22719
- ok: false,
22720
- error: zodAdmissionError(parsed.error)
22721
- };
22722
- }
22723
22751
  /** Validates raw editor state without granting a stored-content proof. */
22724
22752
  function validateManifestDraft(contract, data) {
22725
22753
  const parsed = contract[compiledManifestContentContract].draftSchema.safeParse(data);
@@ -22737,21 +22765,6 @@ function validateManifestDraft(contract, data) {
22737
22765
  };
22738
22766
  }
22739
22767
  /**
22740
- * Applies a pure runtime projection to admitted stored content and validates
22741
- * the resulting render shape before granting the renderable proof.
22742
- */
22743
- function deriveRenderableManifestContent(contract, stored, project) {
22744
- const projected = project(stored);
22745
- const parsed = contract[compiledManifestContentContract].renderSchema.safeParse(projected);
22746
- return parsed.success ? {
22747
- ok: true,
22748
- value: projected
22749
- } : {
22750
- ok: false,
22751
- error: zodAdmissionError(parsed.error)
22752
- };
22753
- }
22754
- /**
22755
22768
  * Re-admits renderable content after a transport boundary has erased its
22756
22769
  * compile-time proof. Unlike write admission, this never canonicalizes or
22757
22770
  * grants a stored proof, so hydrated media cannot flow back to persistence.
@@ -22907,6 +22920,23 @@ function defineLookOptionGroup(group) {
22907
22920
  defaultOption(group);
22908
22921
  return group;
22909
22922
  }
22923
+ function resolveLookOption(group, requestedOptionId) {
22924
+ const fallback = defaultOption(group);
22925
+ if (requestedOptionId === null || requestedOptionId === void 0) return {
22926
+ kind: "defaulted",
22927
+ option: fallback
22928
+ };
22929
+ const selected = group.options.find((option) => option.id === requestedOptionId);
22930
+ return selected ? {
22931
+ kind: "selected",
22932
+ option: selected
22933
+ } : {
22934
+ kind: "repaired",
22935
+ requestedOptionId,
22936
+ option: fallback,
22937
+ reason: "unsupported-option"
22938
+ };
22939
+ }
22910
22940
  function defaultOption(group) {
22911
22941
  const option = group.options.find((candidate) => candidate.id === group.defaultOptionId);
22912
22942
  if (option) return option;
@@ -23797,6 +23827,12 @@ function cardRoleFromLegacyVocabulary(value) {
23797
23827
  */
23798
23828
  const cardRoleWithLegacyAliasSchema = z$1.preprocess((value) => cardRoleFromLegacyVocabulary(value) ?? value, cardRoleSchema);
23799
23829
  //#endregion
23830
+ //#region ../theme-core/src/site-styles/compilationPlan.ts
23831
+ const defaultHeaderBrandPreferences = {
23832
+ brandDisplay: "auto",
23833
+ logoSize: "auto"
23834
+ };
23835
+ //#endregion
23800
23836
  //#region ../theme-core/src/site-styles/sectionTransitions.ts
23801
23837
  function boundaryRuntimeMetrics(family, size, heightExpression) {
23802
23838
  return {
@@ -26178,6 +26214,12 @@ const overlayLogoAccommodations = [
26178
26214
  inlineMarkWithTitleLogoAccommodation,
26179
26215
  unbackedOverlayLogoAccommodation
26180
26216
  ];
26217
+ const allCuratedLogoAccommodations = [
26218
+ ...inlineClassicLogoAccommodations,
26219
+ ...centeredLogoAccommodations,
26220
+ ...overlayLogoAccommodations
26221
+ ];
26222
+ new Set(allCuratedLogoAccommodations.filter((option) => option.providesBacking === true).map((option) => option.pattern));
26181
26223
  const headerLayoutCatalog = defineExactCatalog()([
26182
26224
  {
26183
26225
  id: asHeaderLayoutId("classic"),
@@ -26327,9 +26369,50 @@ defineLookOptionGroup({
26327
26369
  {
26328
26370
  id: "secondary",
26329
26371
  label: "Secondary"
26372
+ },
26373
+ {
26374
+ id: headerInteractionQuietPalette,
26375
+ label: "Text only"
26330
26376
  }
26331
26377
  ]
26332
26378
  });
26379
+ /**
26380
+ * Key-sorted serialisation, because one side of the comparison is built here
26381
+ * and the other comes back out of `jsonb` in whatever order it was stored.
26382
+ * `JSON.stringify` alone reported two identical effect lists as different,
26383
+ * which is the same key-order trap the `logoText` derivation hit in Phase 4a.
26384
+ */
26385
+ function canonicalJson(value) {
26386
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
26387
+ if (typeof value === "object" && value !== null) return `{${Object.entries(value).filter(([, member]) => member !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`).join(",")}}`;
26388
+ return value === void 0 ? "undefined" : JSON.stringify(value);
26389
+ }
26390
+ //#endregion
26391
+ //#region ../theme-core/src/site-styles/headerNavStyles.ts
26392
+ function applyNavTypography(style, typography) {
26393
+ switch (typography) {
26394
+ case "plain": return style;
26395
+ case "heading": return {
26396
+ ...style,
26397
+ typography: "heading",
26398
+ fontWeight: "medium",
26399
+ letterSpacing: "wide"
26400
+ };
26401
+ case "quiet-caps": return {
26402
+ ...style,
26403
+ textTransform: "uppercase",
26404
+ textSize: "sm",
26405
+ letterSpacing: "wide"
26406
+ };
26407
+ case "brand-caps": return {
26408
+ ...style,
26409
+ textTransform: "uppercase",
26410
+ textSize: "sm",
26411
+ letterSpacing: "wider"
26412
+ };
26413
+ default: return assertNever$19(typography, "Unhandled header nav typography");
26414
+ }
26415
+ }
26333
26416
  function asFooterLookId(value) {
26334
26417
  return value;
26335
26418
  }
@@ -27038,6 +27121,37 @@ function uniformSectionForeground(token) {
27038
27121
  supportingColor: token
27039
27122
  };
27040
27123
  }
27124
+ function compileTransitionRuntime(input) {
27125
+ return {
27126
+ treatment: input.treatment,
27127
+ from: input.from,
27128
+ to: input.to,
27129
+ cssVars: {
27130
+ from: transitionEndpointCssValue(input.from),
27131
+ to: transitionEndpointCssValue(input.to)
27132
+ }
27133
+ };
27134
+ }
27135
+ function transitionEndpointCssValue(endpoint) {
27136
+ switch (endpoint.kind) {
27137
+ case "color-token": return themeColorTokenCssValue(endpoint.token);
27138
+ case "surface": return themeColorTokenCssValue(tokenForTransitionSurface(endpoint.surface));
27139
+ default: return assertNever$19(endpoint);
27140
+ }
27141
+ }
27142
+ function tokenForTransitionSurface(surface) {
27143
+ switch (surface) {
27144
+ case "base":
27145
+ case "hero-image": return "background";
27146
+ case "muted-band": return "muted";
27147
+ case "accent-band": return "accent";
27148
+ case "brand-depth-band": return "primary-800";
27149
+ default: return assertNever$19(surface);
27150
+ }
27151
+ }
27152
+ function themeColorTokenCssValue(token) {
27153
+ return `rgb(var(--tb-${token}))`;
27154
+ }
27041
27155
  //#endregion
27042
27156
  //#region ../theme-core/src/site-styles/curatedSiteStyles.ts
27043
27157
  const SOFT_EARTH_SITE_STYLE_ID_VALUE = "site-style:soft-earth";
@@ -28745,6 +28859,142 @@ const blockDesignFallback = {
28745
28859
  };
28746
28860
  blockDesignFallback.sectionSurface, blockDesignFallback.contentFrame, blockDesignFallback.transitionAfter, blockDesignFallback.sectionHeight, blockDesignFallback.heroLegibility, blockDesignFallback.imageSurfaceTreatment, blockDesignFallback.imageSurfaceMotion, blockDesignFallback.tileTreatment, blockDesignFallback.eventListTreatment, blockDesignFallback.eyebrowTreatment, blockDesignFallback.accordionIconTreatment, blockDesignFallback.composition;
28747
28861
  //#endregion
28862
+ //#region ../theme-core/src/site-styles/siteChromeBackgrounds.ts
28863
+ const siteChromeBackgroundColorTokens = toSiteChromeBackgroundColorTokens(backgroundAppearanceColorTokenNames);
28864
+ const quickSiteChromeBackgroundColorTokens = toSiteChromeBackgroundColorTokens(backgroundAppearanceQuickColorTokenNames);
28865
+ siteChromeBackgroundColorTokens.filter((token) => !quickSiteChromeBackgroundColorTokens.includes(token));
28866
+ function parseSiteChromeBackgroundColorToken(value) {
28867
+ const token = parseBackgroundAppearanceColorToken(value);
28868
+ return token ? token : null;
28869
+ }
28870
+ function toSiteChromeBackgroundColorTokens(tokenNames) {
28871
+ return tokenNames.flatMap((name) => {
28872
+ const token = parseSiteChromeBackgroundColorToken(name);
28873
+ return token ? [token] : [];
28874
+ });
28875
+ }
28876
+ //#endregion
28877
+ //#region ../theme-core/src/site-styles/siteChromeNavPreferences.ts
28878
+ const headerNavTypographyPreferenceValues = [
28879
+ "inherit",
28880
+ "body",
28881
+ "heading"
28882
+ ];
28883
+ const headerNavFontSizePreferenceValues = [
28884
+ "inherit",
28885
+ "sm",
28886
+ "base",
28887
+ "lg",
28888
+ "xl"
28889
+ ];
28890
+ const defaultHeaderNavPreferences = {
28891
+ typography: "inherit",
28892
+ fontSize: "inherit",
28893
+ interactionPalette: "recommended"
28894
+ };
28895
+ /**
28896
+ * Applies the user's nav font-role/size overrides on top of a style-resolved
28897
+ * nav link style. This is where the precedence contract lands:
28898
+ *
28899
+ * - The nav typography TREATMENT (case/tracking identity: plain / heading /
28900
+ * quiet-caps / brand-caps) is ALWAYS owned by the active header style and is
28901
+ * baked into `style` before this call (see `applyNavTypography` in
28902
+ * headerLayoutStyles.ts).
28903
+ * - Each treatment resolves through a DEFAULT font role: `heading` resolves
28904
+ * through the heading role; `plain` / `quiet-caps` / `brand-caps` resolve
28905
+ * through the body role.
28906
+ * - The user's `navFontRole` (surfaced here as `preferences.typography`) only
28907
+ * overrides which font ROLE the treatment resolves through. It never changes
28908
+ * the treatment. `'inherit'` keeps the treatment's default role.
28909
+ */
28910
+ function mergeHeaderNavTypographyPreference(style, preferences) {
28911
+ return preferences.typography === "inherit" && preferences.fontSize === "inherit" ? style : {
28912
+ ...style,
28913
+ ...preferences.typography === "inherit" ? {} : { typography: preferences.typography },
28914
+ ...preferences.fontSize === "inherit" ? {} : { textSize: preferences.fontSize }
28915
+ };
28916
+ }
28917
+ function readHeaderNavFontSizePreference(value) {
28918
+ return value === "sm" || value === "base" || value === "lg" || value === "xl" ? value : "inherit";
28919
+ }
28920
+ //#endregion
28921
+ //#region ../theme-core/src/site-styles/headerBrandPresentation.ts
28922
+ /**
28923
+ * Whether the header wears a brand presentation at all (#1318 Phase 4c).
28924
+ *
28925
+ * `applyHeaderBrandPresentation` is unconditional today: given a logo context
28926
+ * it always writes a `logo` object carrying a resolved display, size and
28927
+ * accommodation. That makes "this header has no brand presentation block" —
28928
+ * which is what several live sites persist, including one whose site *does*
28929
+ * have a logo asset — a state the preference record could not express, so a
28930
+ * recompile would silently give the site a logo treatment it has never
28931
+ * rendered.
28932
+ *
28933
+ * This is the third field in this phase with that shape, after the interaction
28934
+ * palette and the nav text size: the persisted bytes carry intent the reader
28935
+ * cannot say, so the recompile "corrects" it. The fix is the same one — widen
28936
+ * the closed vocabulary with a named member and derive it by baseline
28937
+ * comparison — and it is a *widening*, not a restyle: `'recommended'` is the
28938
+ * default and reproduces today's behaviour exactly, so no row whose bytes carry
28939
+ * a presentation compiles differently for `'plain'` existing.
28940
+ *
28941
+ * `'plain'` is deliberately not "preserve whatever bytes are there". It is a
28942
+ * constructive instruction — *write no presentation block* — so Phase 4e's
28943
+ * compiler, which builds the header from nothing, can produce it.
28944
+ */
28945
+ const headerBrandPresentationValues = ["recommended", "plain"];
28946
+ /**
28947
+ * Key-order-safe structural equality over the persisted `logo` object.
28948
+ *
28949
+ * The persisted value comes back from `jsonb`, whose key order is not the order
28950
+ * the applier builds; a `JSON.stringify` comparison would read two equal logo
28951
+ * blocks as different and quietly classify a `'recommended'` row as
28952
+ * unrecognised — the same trap the interaction-palette transcription test
28953
+ * exists to catch.
28954
+ */
28955
+ function jsonEquals(left, right) {
28956
+ if (left === right) return true;
28957
+ if (left === null || right === null) return false;
28958
+ if (typeof left !== "object" || typeof right !== "object") return false;
28959
+ if (Array.isArray(left) !== Array.isArray(right)) return false;
28960
+ if (Array.isArray(left) && Array.isArray(right)) return left.length === right.length && left.every((value, index) => jsonEquals(value, right[index]));
28961
+ const leftRecord = left;
28962
+ const rightRecord = right;
28963
+ const keys = /* @__PURE__ */ new Set([...Object.keys(leftRecord), ...Object.keys(rightRecord)]);
28964
+ for (const key of keys) if (!jsonEquals(leftRecord[key], rightRecord[key])) return false;
28965
+ return true;
28966
+ }
28967
+ defaultHeaderBrandPreferences.brandDisplay, defaultHeaderBrandPreferences.logoSize, defaultHeaderNavPreferences.typography, defaultHeaderNavPreferences.fontSize, defaultHeaderNavPreferences.interactionPalette;
28968
+ function retargetCompiledBoundaryRuntimeForContentAdjacency(boundary, adjacency) {
28969
+ const fromSurface = contentOwnedBackgroundEndpoint(adjacency.currentContent) ?? boundary.from;
28970
+ const toSurface = contentOwnedBackgroundEndpoint(adjacency.nextContent) ?? boundary.to;
28971
+ if (fromSurface === boundary.from && toSurface === boundary.to) return boundary;
28972
+ const transition = compileTransitionRuntime({
28973
+ treatment: boundary.treatment,
28974
+ from: fromSurface,
28975
+ to: toSurface
28976
+ });
28977
+ return {
28978
+ ...boundary,
28979
+ from: transition.from,
28980
+ to: transition.to,
28981
+ cssVars: transition.cssVars
28982
+ };
28983
+ }
28984
+ function contentOwnedBackgroundEndpoint(content) {
28985
+ const background = readRecord(readRecord(content?._sectionStyles)?.background);
28986
+ if (background?.contentOwner !== "page-design-editor" || background.type !== "color") return null;
28987
+ const token = parseThemeColorToken(background.color);
28988
+ if (!token) return null;
28989
+ return {
28990
+ kind: "color-token",
28991
+ token
28992
+ };
28993
+ }
28994
+ function readRecord(value) {
28995
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
28996
+ }
28997
+ //#endregion
28748
28998
  //#region ../theme-core/src/site-styles/generatedSchemas.ts
28749
28999
  function uniqueNonEmpty(values) {
28750
29000
  const uniqueValues = [...new Set(values)];
@@ -28813,6 +29063,76 @@ z$1.object({
28813
29063
  blocks: z$1.array(generatedPageDesignBlockIntentSchema)
28814
29064
  }).strict();
28815
29065
  //#endregion
29066
+ //#region ../theme-core/src/site-styles/headerPreferencesPersistence.ts
29067
+ const backgroundColorTokenSchema = z$1.unknown().transform((value, ctx) => {
29068
+ const token = parseSiteChromeBackgroundColorToken(value);
29069
+ if (token === null) {
29070
+ ctx.addIssue({
29071
+ code: "custom",
29072
+ message: "Unknown site chrome background colour token"
29073
+ });
29074
+ return z$1.NEVER;
29075
+ }
29076
+ return token;
29077
+ });
29078
+ const backgroundOverrideSchema = z$1.discriminatedUnion("kind", [z$1.object({ kind: z$1.literal("default") }).strict(), z$1.object({
29079
+ kind: z$1.literal("color"),
29080
+ token: backgroundColorTokenSchema
29081
+ }).strict()]);
29082
+ const logoTextIntentSchema = z$1.object({
29083
+ mobileMaxWidth: z$1.enum([
29084
+ "xs",
29085
+ "sm",
29086
+ "md"
29087
+ ]).optional(),
29088
+ mobileWrapLines: z$1.union([z$1.literal(1), z$1.literal(2)]).optional(),
29089
+ hideOnShrink: z$1.boolean().optional()
29090
+ }).strict();
29091
+ const structureSchema = z$1.object({
29092
+ positioning: z$1.union([z$1.literal("inherit"), z$1.enum(headerPositioningValues)]),
29093
+ shrinkOnScroll: z$1.union([z$1.literal("inherit"), z$1.boolean()]),
29094
+ logoText: z$1.union([
29095
+ z$1.literal("inherit"),
29096
+ z$1.literal("none"),
29097
+ logoTextIntentSchema
29098
+ ])
29099
+ }).strict();
29100
+ const headerPreferencesShape = {
29101
+ brandPresentation: z$1.enum(headerBrandPresentationValues),
29102
+ brandDisplay: z$1.enum(brandDisplayModeValues),
29103
+ logoSize: z$1.enum(logoSizePolicyValues),
29104
+ navFontRole: z$1.enum(headerNavTypographyPreferenceValues),
29105
+ navTextSize: z$1.enum(headerNavFontSizePreferenceValues),
29106
+ interactionPalette: z$1.union([z$1.literal("recommended"), z$1.enum(headerInteractionPaletteValues)]),
29107
+ background: backgroundOverrideSchema,
29108
+ structure: structureSchema
29109
+ };
29110
+ z$1.object(headerPreferencesShape).strict();
29111
+ z$1.object(headerPreferencesShape).omit({
29112
+ brandPresentation: true,
29113
+ structure: true
29114
+ }).strict();
29115
+ //#endregion
29116
+ //#region ../theme-core/src/site-styles/headerBytesComparison.ts
29117
+ function isPlainObject$5(value) {
29118
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29119
+ }
29120
+ /**
29121
+ * Structural diff by JSON path. Values are compared through
29122
+ * `JSON.stringify`, so a key present with the value `undefined` and an absent
29123
+ * key read as the same thing — which is what `jsonb` does to them anyway
29124
+ * (#1318 Correction 5).
29125
+ */
29126
+ function diffJsonPaths(left, right, path = "") {
29127
+ if (isPlainObject$5(left) && isPlainObject$5(right)) return [.../* @__PURE__ */ new Set([...Object.keys(left), ...Object.keys(right)])].sort().flatMap((key) => diffJsonPaths(left[key], right[key], path ? `${path}.${key}` : key));
29128
+ if (JSON.stringify(left) === JSON.stringify(right)) return [];
29129
+ return [{
29130
+ path,
29131
+ left,
29132
+ right
29133
+ }];
29134
+ }
29135
+ //#endregion
28816
29136
  //#region ../theme-core/src/site-styles/pageDesignAuthority.ts
28817
29137
  const nonEmptyStringSchema = z$1.string().min(1);
28818
29138
  const blockContentVersionIdSchema = nonEmptyStringSchema.transform(asBlockContentVersionId);
@@ -28914,46 +29234,6 @@ function collectBlockSequenceIssues(snapshots) {
28914
29234
  return issues;
28915
29235
  }
28916
29236
  //#endregion
28917
- //#region ../theme-core/src/site-styles/siteChromeBackgrounds.ts
28918
- const siteChromeBackgroundColorTokens = toSiteChromeBackgroundColorTokens(backgroundAppearanceColorTokenNames);
28919
- const quickSiteChromeBackgroundColorTokens = toSiteChromeBackgroundColorTokens(backgroundAppearanceQuickColorTokenNames);
28920
- siteChromeBackgroundColorTokens.filter((token) => !quickSiteChromeBackgroundColorTokens.includes(token));
28921
- function parseSiteChromeBackgroundColorToken(value) {
28922
- const token = parseBackgroundAppearanceColorToken(value);
28923
- return token ? token : null;
28924
- }
28925
- function toSiteChromeBackgroundColorTokens(tokenNames) {
28926
- return tokenNames.flatMap((name) => {
28927
- const token = parseSiteChromeBackgroundColorToken(name);
28928
- return token ? [token] : [];
28929
- });
28930
- }
28931
- /**
28932
- * Applies the user's nav font-role/size overrides on top of a style-resolved
28933
- * nav link style. This is where the precedence contract lands:
28934
- *
28935
- * - The nav typography TREATMENT (case/tracking identity: plain / heading /
28936
- * quiet-caps / brand-caps) is ALWAYS owned by the active header style and is
28937
- * baked into `style` before this call (see `applyNavTypography` in
28938
- * headerLayoutStyles.ts).
28939
- * - Each treatment resolves through a DEFAULT font role: `heading` resolves
28940
- * through the heading role; `plain` / `quiet-caps` / `brand-caps` resolve
28941
- * through the body role.
28942
- * - The user's `navFontRole` (surfaced here as `preferences.typography`) only
28943
- * overrides which font ROLE the treatment resolves through. It never changes
28944
- * the treatment. `'inherit'` keeps the treatment's default role.
28945
- */
28946
- function mergeHeaderNavTypographyPreference(style, preferences) {
28947
- return preferences.typography === "inherit" && preferences.fontSize === "inherit" ? style : {
28948
- ...style,
28949
- ...preferences.typography === "inherit" ? {} : { typography: preferences.typography },
28950
- ...preferences.fontSize === "inherit" ? {} : { textSize: preferences.fontSize }
28951
- };
28952
- }
28953
- function readHeaderNavFontSizePreference(value) {
28954
- return value === "sm" || value === "base" || value === "lg" || value === "xl" ? value : "inherit";
28955
- }
28956
- //#endregion
28957
29237
  //#region ../theme-core/src/site-styles/tokenRecipes.ts
28958
29238
  const themeTokenRecipeOptions = {
28959
29239
  palette: [
@@ -40775,7 +41055,7 @@ const eventDisplayFields = [
40775
41055
  {
40776
41056
  id: "showCapacity",
40777
41057
  type: "boolean",
40778
- label: "Show available spots",
41058
+ label: "Show available places",
40779
41059
  description: "Display remaining capacity",
40780
41060
  required: false,
40781
41061
  defaultValue: true,
@@ -41100,7 +41380,272 @@ function planEventRegistrationScope(input) {
41100
41380
  };
41101
41381
  }
41102
41382
  //#endregion
41103
- //#region ../blocks/src/system/runtime/nodes/events/shared/destination.ts
41383
+ //#region ../blocks/src/system/runtime/nodes/events/domain/capacity.ts
41384
+ /**
41385
+ * Compute remaining spots for an event from `availableSpots` with a fallback
41386
+ * to `capacity - registeredCount`. Returns `{ available: null, isFull: false }`
41387
+ * for uncapped events so call sites can render no capacity indicator at all.
41388
+ *
41389
+ * The single source of truth for capacity math across event cards, agenda
41390
+ * rows, and timetable cells — keep wording variations in the call sites.
41391
+ */
41392
+ function getEventAvailability(event) {
41393
+ if (event.capacity == null) return {
41394
+ available: null,
41395
+ isFull: false
41396
+ };
41397
+ const available = event.availableSpots ?? event.capacity - event.registeredCount;
41398
+ if (available <= 0) return {
41399
+ available: 0,
41400
+ isFull: true
41401
+ };
41402
+ return {
41403
+ available,
41404
+ isFull: false
41405
+ };
41406
+ }
41407
+ /**
41408
+ * Pick the capacity row a booking decision should read: the selected
41409
+ * attendance mode's availability when the visitor has chosen one and the
41410
+ * event publishes it, otherwise the event-level aggregate.
41411
+ *
41412
+ * The mode row is used wholesale. A mode with `capacity: null` on a capped
41413
+ * event is uncapped *by design*: the registration write path applies the
41414
+ * same rule via `getEffectiveOccurrenceCapacityForAttendanceMode`, so falling
41415
+ * back to the event's `availableSpots` here would block bookings the server
41416
+ * accepts. (Waitlist claims are still occurrence-scoped; see issue 1377.)
41417
+ */
41418
+ function selectEventCapacitySource(event, attendanceMode) {
41419
+ if (!attendanceMode) return event;
41420
+ return (event.attendanceModes ?? []).find((mode) => mode.mode === attendanceMode) ?? event;
41421
+ }
41422
+ /**
41423
+ * Whether the visitor's next step is the waitlist: the source is full *and*
41424
+ * a waitlist is open. The one place this is decided — cards, rows, the
41425
+ * wizard's steps and its action bar all route through here so they agree,
41426
+ * including for producers that omit `availableSpots` and rely on the
41427
+ * `capacity - registeredCount` fallback.
41428
+ */
41429
+ function canJoinEventWaitlist(event, options) {
41430
+ const source = selectEventCapacitySource(event, options?.attendanceMode);
41431
+ return getEventAvailability(source).isFull && source.waitlistEnabled === true;
41432
+ }
41433
+ /**
41434
+ * Remaining places at or below which an event reads as scarce and the exact
41435
+ * count is worth showing. Above it, no number is shown at all: "147 places
41436
+ * left" is not something a visitor acts on, and it is the figure most likely
41437
+ * to be stale on a cached page.
41438
+ *
41439
+ * max( min(3, ceil(capacity * 0.25)), ceil(capacity * 0.05) )
41440
+ *
41441
+ * The 25% arm stops small groups shouting early (3 of 4 is not urgency); the
41442
+ * 5% floor stops large events staying silent until three remain.
41443
+ * capacity 4 -> 1, 12 -> 3, 40 -> 3, 100 -> 5, 200 -> 10.
41444
+ */
41445
+ function lowCapacityThreshold(capacity) {
41446
+ return Math.max(Math.min(3, Math.ceil(capacity * .25)), Math.ceil(capacity * .05));
41447
+ }
41448
+ function getRemainingPlaces(source) {
41449
+ const { available, isFull } = getEventAvailability(source);
41450
+ if (available === null || source.capacity === null) return { kind: "unlimited" };
41451
+ if (isFull) return {
41452
+ kind: "full",
41453
+ waitlist: canJoinEventWaitlist(source)
41454
+ };
41455
+ if (available <= lowCapacityThreshold(source.capacity)) return {
41456
+ kind: "low",
41457
+ remaining: available
41458
+ };
41459
+ return { kind: "open" };
41460
+ }
41461
+ /**
41462
+ * The one place a remaining-places count becomes text. Returns `null` when
41463
+ * nothing should be shown: uncapped events, and capped events above the
41464
+ * scarcity threshold. Surfaces that need their own full-state wording pass
41465
+ * it in rather than re-deriving the count.
41466
+ */
41467
+ function formatRemainingPlaces(source, wording = {}) {
41468
+ const remaining = getRemainingPlaces(source);
41469
+ switch (remaining.kind) {
41470
+ case "unlimited":
41471
+ case "open": return null;
41472
+ case "low": return formatPlacesLeft(remaining.remaining, wording.compact === true);
41473
+ case "full": return remaining.waitlist ? wording.waitlist ?? "Waitlist" : wording.full ?? "Full";
41474
+ }
41475
+ }
41476
+ function formatPlacesLeft(count, compact) {
41477
+ if (compact) return `${count} left`;
41478
+ return count === 1 ? "1 place left" : `${count} places left`;
41479
+ }
41480
+ /** Total capacity as copy ("12 places"), for detail surfaces that state the size of an event. */
41481
+ function formatTotalCapacity(capacity) {
41482
+ if (capacity === null) return null;
41483
+ return capacity === 1 ? "1 place" : `${capacity} places`;
41484
+ }
41485
+ function getEventAvailabilityStatus(event) {
41486
+ const source = selectMostOpenCapacitySource(event);
41487
+ const remaining = getRemainingPlaces(source);
41488
+ const label = formatEventRemainingPlaces(event, { compact: true });
41489
+ switch (remaining.kind) {
41490
+ case "unlimited": return {
41491
+ status: "available",
41492
+ available: null,
41493
+ isFull: false,
41494
+ label
41495
+ };
41496
+ case "open": return {
41497
+ status: "available",
41498
+ available: getEventAvailability(source).available,
41499
+ isFull: false,
41500
+ label
41501
+ };
41502
+ case "low": return {
41503
+ status: "filling",
41504
+ available: remaining.remaining,
41505
+ isFull: false,
41506
+ label
41507
+ };
41508
+ case "full": return {
41509
+ status: "waitlist",
41510
+ available: 0,
41511
+ isFull: true,
41512
+ label
41513
+ };
41514
+ }
41515
+ }
41516
+ /**
41517
+ * Which capacity figure wins when they disagree.
41518
+ *
41519
+ * For a hybrid event that publishes both attendance-mode rows, **the mode
41520
+ * rows are authoritative and the event-level aggregate is only a summary**.
41521
+ * The registration write path admits or refuses per mode
41522
+ * (`getEffectiveOccurrenceCapacityForAttendanceMode` on the server), so the
41523
+ * aggregate can read full while a mode still has room, and can read open
41524
+ * while the mode the visitor wants is full. Believing the aggregate on a
41525
+ * card is how a "places available" event turns into a dead end at step two.
41526
+ *
41527
+ * Everything else — single-mode events, events that publish no rows, and the
41528
+ * aggregate-only helpers {@link getEventAvailability} /
41529
+ * {@link getRemainingPlaces} that existing callers rely on — keeps reading
41530
+ * the aggregate, unchanged.
41531
+ */
41532
+ function findHybridModeRows(event) {
41533
+ if (event.deliveryMode !== "hybrid") return null;
41534
+ const rows = event.attendanceModes ?? [];
41535
+ const inPerson = rows.find((row) => row.mode === "in_person");
41536
+ const online = rows.find((row) => row.mode === "online");
41537
+ return inPerson && online ? {
41538
+ inPerson,
41539
+ online
41540
+ } : null;
41541
+ }
41542
+ /** Openness rank; higher means more room. A full mode with a waitlist beats one without. */
41543
+ function remainingPlacesRank(remaining) {
41544
+ switch (remaining.kind) {
41545
+ case "unlimited": return 4;
41546
+ case "open": return 3;
41547
+ case "low": return 2;
41548
+ case "full": return remaining.waitlist ? 1 : 0;
41549
+ }
41550
+ }
41551
+ function isMoreOpen(candidate, than) {
41552
+ const rank = remainingPlacesRank(candidate);
41553
+ const otherRank = remainingPlacesRank(than);
41554
+ if (rank !== otherRank) return rank > otherRank;
41555
+ return candidate.kind === "low" && than.kind === "low" ? candidate.remaining > than.remaining : false;
41556
+ }
41557
+ /** True when `candidate` has less room left than `than`. */
41558
+ function isScarcer(candidate, than) {
41559
+ return isMoreOpen(than, candidate);
41560
+ }
41561
+ /**
41562
+ * The capacity row a whole-event decision should read: the most open
41563
+ * attendance mode of a hybrid event, otherwise the aggregate. A hybrid event
41564
+ * is sold out only when both of its modes are, and its waitlist is reachable
41565
+ * when either full mode has one open.
41566
+ */
41567
+ function selectMostOpenCapacitySource(event) {
41568
+ const rows = findHybridModeRows(event);
41569
+ if (!rows) return event;
41570
+ return isMoreOpen(getRemainingPlaces(rows.online), getRemainingPlaces(rows.inPerson)) ? rows.online : rows.inPerson;
41571
+ }
41572
+ /** True when no attendance mode has room. Hybrid-aware; use this for sold-out decisions. */
41573
+ function isEventFull(event) {
41574
+ return getEventAvailability(selectMostOpenCapacitySource(event)).isFull;
41575
+ }
41576
+ /**
41577
+ * True when the event is full on every mode and at least one of those full
41578
+ * modes has a waitlist open. An event with a mode still bookable is not a
41579
+ * waitlist prospect: it should send the visitor to that mode instead.
41580
+ */
41581
+ function canJoinEventWaitlistOnAnyMode(event) {
41582
+ return canJoinEventWaitlist(selectMostOpenCapacitySource(event));
41583
+ }
41584
+ const ATTENDANCE_MODE_LABEL = {
41585
+ in_person: "In person",
41586
+ online: "Online"
41587
+ };
41588
+ /** Wording for a mode with room to spare, where the count is deliberately withheld. */
41589
+ const PLACES_AVAILABLE = "Places available";
41590
+ /**
41591
+ * The browse-surface label for an event's remaining places. Single-mode
41592
+ * events get exactly the aggregate label {@link formatRemainingPlaces}
41593
+ * produces, and so does a hybrid event whose two modes tell the same story.
41594
+ * When the modes differ, both sides are named: "available" on a card is a
41595
+ * promise the registration wizard would otherwise have to break.
41596
+ *
41597
+ * Hybrid examples, in person full with 3 online places left:
41598
+ * default -> "Online: 3 places left · In person: Full"
41599
+ * { full: 'Sold out' } -> "Online: 3 places left · In person: Sold out"
41600
+ * { compact: true } -> "Online: 3 left"
41601
+ */
41602
+ function formatEventRemainingPlaces(event, wording = {}) {
41603
+ const rows = findHybridModeRows(event);
41604
+ if (!rows) return formatRemainingPlaces(event, wording);
41605
+ const inPerson = formatRemainingPlaces(rows.inPerson, wording);
41606
+ const online = formatRemainingPlaces(rows.online, wording);
41607
+ if (inPerson === online) return inPerson;
41608
+ if (wording.compact === true) return formatCompactHybridPlaces(rows, wording);
41609
+ return formatBothHybridSides(rows, {
41610
+ inPerson,
41611
+ online
41612
+ });
41613
+ }
41614
+ /**
41615
+ * Both sides, the one with room first: that is the half a visitor can act on,
41616
+ * and it should not arrive after a full stop's worth of bad news.
41617
+ */
41618
+ function formatBothHybridSides(rows, labels) {
41619
+ return (isMoreOpen(getRemainingPlaces(rows.online), getRemainingPlaces(rows.inPerson)) ? [["online", labels.online], ["in_person", labels.inPerson]] : [["in_person", labels.inPerson], ["online", labels.online]]).map(([mode, label]) => namedModePlaces(mode, label)).join(" · ");
41620
+ }
41621
+ function namedModePlaces(mode, label) {
41622
+ return `${ATTENDANCE_MODE_LABEL[mode]}: ${label ?? PLACES_AVAILABLE}`;
41623
+ }
41624
+ /**
41625
+ * Pills and timetable cells have room for one short phrase, so they name the
41626
+ * single mode that carries news rather than listing both: the scarce side
41627
+ * when there is a count to show, and otherwise the side that has closed —
41628
+ * "In person: Full", never "Online only", which reads as a statement about
41629
+ * how the event is delivered rather than about its remaining places.
41630
+ */
41631
+ function formatCompactHybridPlaces(rows, wording) {
41632
+ const inPerson = getRemainingPlaces(rows.inPerson);
41633
+ const online = getRemainingPlaces(rows.online);
41634
+ if (inPerson.kind === "full" && online.kind === "full") return formatRemainingPlaces(inPerson.waitlist ? rows.inPerson : rows.online, wording);
41635
+ if (inPerson.kind === "full" || online.kind === "full") {
41636
+ const fullSide = inPerson.kind === "full" ? "in_person" : "online";
41637
+ const openSide = inPerson.kind === "full" ? "online" : "in_person";
41638
+ const openLabel = formatRemainingPlaces(rowFor(rows, openSide), wording);
41639
+ return openLabel !== null ? namedModePlaces(openSide, openLabel) : namedModePlaces(fullSide, formatRemainingPlaces(rowFor(rows, fullSide), wording));
41640
+ }
41641
+ const scarcerSide = isScarcer(online, inPerson) ? "online" : "in_person";
41642
+ return namedModePlaces(scarcerSide, formatRemainingPlaces(rowFor(rows, scarcerSide), wording));
41643
+ }
41644
+ function rowFor(rows, mode) {
41645
+ return mode === "online" ? rows.online : rows.inPerson;
41646
+ }
41647
+ //#endregion
41648
+ //#region ../blocks/src/system/runtime/nodes/events/domain/destination.ts
41104
41649
  function isRecord$23(value) {
41105
41650
  return typeof value === "object" && value !== null && !Array.isArray(value);
41106
41651
  }
@@ -41118,7 +41663,701 @@ function resolveRuntimeEventDestination(event) {
41118
41663
  };
41119
41664
  }
41120
41665
  //#endregion
41121
- //#region ../blocks/src/system/runtime/nodes/events/shared/pricing.ts
41666
+ //#region ../blocks/src/system/runtime/nodes/events/domain/cta.ts
41667
+ /**
41668
+ * Booking call-to-action resolution for events: where an event links to and
41669
+ * what the button says. Sold-out and waitlist decisions come from capacity.ts
41670
+ * so cards, rows and the wizard never disagree.
41671
+ */
41672
+ function closedEventBookingLabel(reason) {
41673
+ switch (reason) {
41674
+ case "occurrence_cancelled":
41675
+ case "series_cancelled": return "Cancelled";
41676
+ case "occurrence_completed":
41677
+ case "series_completed": return "Completed";
41678
+ case "series_draft":
41679
+ case "series_archived":
41680
+ case "parent_archived":
41681
+ case "parent_inconsistent":
41682
+ case "not_public":
41683
+ case "already_started":
41684
+ case "enrollment_closed": return "Registration closed";
41685
+ default: return assertNever$24(reason);
41686
+ }
41687
+ }
41688
+ function resolveEventHref(event) {
41689
+ const destination = resolveRuntimeEventDestination(event);
41690
+ return destination.kind === "external" ? destination.url : destination.path;
41691
+ }
41692
+ function resolveEventLinkProps(event) {
41693
+ const href = resolveEventHref(event);
41694
+ return resolveRuntimeEventDestination(event).kind === "external" ? {
41695
+ href,
41696
+ target: "_blank",
41697
+ rel: "noopener noreferrer"
41698
+ } : { href };
41699
+ }
41700
+ /**
41701
+ * Resolve the CTA state for an event.
41702
+ * Shared by EventCard and EventCompactRow to keep sold-out, registration-closed,
41703
+ * and course-specific defaults consistent.
41704
+ */
41705
+ function resolveEventCta(event, buttonText) {
41706
+ const isSoldOut = isEventFull(event);
41707
+ const destination = resolveRuntimeEventDestination(event);
41708
+ const isExternal = destination.kind === "external";
41709
+ const defaultButtonText = isExternal ? destination.label : event.bookingScope === "whole_series" ? "Book course" : "Book";
41710
+ const effectiveButtonText = buttonText ?? defaultButtonText;
41711
+ const href = resolveEventHref(event);
41712
+ if (event.bookingAvailability.kind === "closed") {
41713
+ const linksToClosedWholeSeriesDetails = event.bookingAvailability.reason === "enrollment_closed" && !isExternal;
41714
+ return {
41715
+ label: closedEventBookingLabel(event.bookingAvailability.reason),
41716
+ href,
41717
+ external: isExternal,
41718
+ hidden: !linksToClosedWholeSeriesDetails
41719
+ };
41720
+ }
41721
+ if (!isExternal && isSoldOut) {
41722
+ if (canJoinEventWaitlistOnAnyMode(event)) return {
41723
+ label: "Join waitlist",
41724
+ href,
41725
+ external: false,
41726
+ hidden: false
41727
+ };
41728
+ return {
41729
+ label: "Sold out",
41730
+ href,
41731
+ external: false,
41732
+ hidden: true
41733
+ };
41734
+ }
41735
+ return {
41736
+ label: effectiveButtonText,
41737
+ href,
41738
+ external: isExternal,
41739
+ hidden: false
41740
+ };
41741
+ }
41742
+ //#endregion
41743
+ //#region ../calendar/src/grid.ts
41744
+ const DAY_NAMES = [
41745
+ "Sun",
41746
+ "Mon",
41747
+ "Tue",
41748
+ "Wed",
41749
+ "Thu",
41750
+ "Fri",
41751
+ "Sat"
41752
+ ];
41753
+ const MONTH_NAMES = [
41754
+ "January",
41755
+ "February",
41756
+ "March",
41757
+ "April",
41758
+ "May",
41759
+ "June",
41760
+ "July",
41761
+ "August",
41762
+ "September",
41763
+ "October",
41764
+ "November",
41765
+ "December"
41766
+ ];
41767
+ function formatDateKey(date) {
41768
+ return `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`;
41769
+ }
41770
+ function isToday(date, today) {
41771
+ return isSameCalendarDay(date, today);
41772
+ }
41773
+ function isSameCalendarDay(left, right) {
41774
+ return left.year === right.year && left.month === right.month && left.day === right.day;
41775
+ }
41776
+ function addDays(date, days) {
41777
+ const result = new Date(Date.UTC(date.year, date.month - 1, date.day + days, 12));
41778
+ return {
41779
+ year: result.getUTCFullYear(),
41780
+ month: result.getUTCMonth() + 1,
41781
+ day: result.getUTCDate()
41782
+ };
41783
+ }
41784
+ function getMonthCalendarDays(year, month, startOfWeek) {
41785
+ const firstDay = {
41786
+ year,
41787
+ month,
41788
+ day: 1
41789
+ };
41790
+ const dayOfWeek = getDayOfWeek(firstDay);
41791
+ const gridStart = addDays(firstDay, -(startOfWeek === "monday" ? dayOfWeek === 0 ? 6 : dayOfWeek - 1 : dayOfWeek));
41792
+ return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index));
41793
+ }
41794
+ function getWeekCalendarDays(baseDate, startOfWeek) {
41795
+ const dayOfWeek = getDayOfWeek(baseDate);
41796
+ const weekStart = addDays(baseDate, -(startOfWeek === "monday" ? dayOfWeek === 0 ? 6 : dayOfWeek - 1 : dayOfWeek));
41797
+ return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
41798
+ }
41799
+ function getDayOfWeek(date) {
41800
+ return new Date(Date.UTC(date.year, date.month - 1, date.day, 12)).getUTCDay();
41801
+ }
41802
+ function getWeekCalendarDayTuple(baseDate, startOfWeek) {
41803
+ const days = getWeekCalendarDays(baseDate, startOfWeek);
41804
+ assertWeekCalendarDays(days);
41805
+ return days;
41806
+ }
41807
+ function assertWeekCalendarDays(days) {
41808
+ if (days.length !== 7) throw new Error("Expected exactly 7 calendar days");
41809
+ }
41810
+ function getOrderedDayNames(startOfWeek, dayNames = DAY_NAMES) {
41811
+ if (startOfWeek === "monday") {
41812
+ const sunday = dayNames[0] ?? "Sun";
41813
+ return [...dayNames.slice(1), sunday];
41814
+ }
41815
+ return [...dayNames];
41816
+ }
41817
+ function formatCalendarTitle(viewDate, view, days, monthNames = MONTH_NAMES) {
41818
+ if (view === "week") {
41819
+ const weekStart = days[0];
41820
+ const weekEnd = days[6];
41821
+ if (weekStart === void 0 || weekEnd === void 0) return `${monthNames[viewDate.month - 1] ?? ""} ${viewDate.year}`;
41822
+ const startMonth = monthNames[weekStart.month - 1] ?? "";
41823
+ const endMonth = monthNames[weekEnd.month - 1] ?? "";
41824
+ if (weekStart.month === weekEnd.month) return `${startMonth} ${weekStart.day} - ${weekEnd.day}, ${weekEnd.year}`;
41825
+ return `${startMonth} ${weekStart.day} - ${endMonth} ${weekEnd.day}, ${weekEnd.year}`;
41826
+ }
41827
+ return `${monthNames[viewDate.month - 1] ?? ""} ${viewDate.year}`;
41828
+ }
41829
+ function buildCalendarViewport(input) {
41830
+ const monthNames = input.monthNames ?? MONTH_NAMES;
41831
+ if (input.view === "week") {
41832
+ const days = getWeekCalendarDayTuple(input.anchorDate, input.startOfWeek);
41833
+ return {
41834
+ kind: "week",
41835
+ anchorDate: input.anchorDate,
41836
+ days,
41837
+ title: formatCalendarTitle(input.anchorDate, "week", days, monthNames)
41838
+ };
41839
+ }
41840
+ const days = getMonthCalendarDays(input.anchorDate.year, input.anchorDate.month, input.startOfWeek);
41841
+ return {
41842
+ kind: "month",
41843
+ anchorDate: input.anchorDate,
41844
+ days,
41845
+ title: formatCalendarTitle(input.anchorDate, "month", days, monthNames)
41846
+ };
41847
+ }
41848
+ //#endregion
41849
+ //#region ../calendar/src/lane-packing.ts
41850
+ /**
41851
+ * Assign lanes to a list of intervals.
41852
+ *
41853
+ * Returns one result per input in the same order as the input. Internally
41854
+ * sorts a copy by `(start, end, orderKey)` to ensure determinism, then
41855
+ * permutes results back to input order.
41856
+ */
41857
+ function assignLanes(intervals) {
41858
+ if (intervals.length === 0) return [];
41859
+ const indexed = intervals.map((interval, index) => ({
41860
+ interval,
41861
+ index
41862
+ }));
41863
+ indexed.sort((left, right) => left.interval.start - right.interval.start || left.interval.end - right.interval.end || left.interval.orderKey.localeCompare(right.interval.orderKey));
41864
+ const results = new Array(intervals.length);
41865
+ let cluster = [];
41866
+ let activeLaneEnds = [];
41867
+ let clusterMaxEnd = -Infinity;
41868
+ const closeCluster = () => {
41869
+ if (cluster.length === 0) return;
41870
+ const laneCount = Math.max(1, cluster.reduce((max, entry) => Math.max(max, entry.laneIndex + 1), 0));
41871
+ for (const entry of cluster) results[entry.index] = {
41872
+ laneIndex: entry.laneIndex,
41873
+ laneCount
41874
+ };
41875
+ cluster = [];
41876
+ activeLaneEnds = [];
41877
+ clusterMaxEnd = -Infinity;
41878
+ };
41879
+ for (const { interval, index } of indexed) {
41880
+ if (interval.start >= clusterMaxEnd) closeCluster();
41881
+ const laneIndex = firstAvailableLane(activeLaneEnds, interval.start);
41882
+ activeLaneEnds[laneIndex] = interval.end;
41883
+ clusterMaxEnd = Math.max(clusterMaxEnd, interval.end);
41884
+ cluster.push({
41885
+ index,
41886
+ laneIndex
41887
+ });
41888
+ }
41889
+ closeCluster();
41890
+ return results;
41891
+ }
41892
+ function firstAvailableLane(activeLaneEnds, start) {
41893
+ const laneIndex = activeLaneEnds.findIndex((end) => end <= start);
41894
+ return laneIndex >= 0 ? laneIndex : activeLaneEnds.length;
41895
+ }
41896
+ //#endregion
41897
+ //#region ../calendar/src/timezone.ts
41898
+ /**
41899
+ * Timezone boundary helpers — internal.
41900
+ *
41901
+ * Both functions read an ISO instant in a given IANA timezone and project it
41902
+ * onto the wall clock of that zone. They use Intl.DateTimeFormat with
41903
+ * hourCycle: 'h23' to avoid 12/24-hour ambiguity. Used internally by the
41904
+ * timetable model and `groupItemsByDate`; not exported from the package's
41905
+ * public surface.
41906
+ */
41907
+ const MINUTES_PER_HOUR = 60;
41908
+ /**
41909
+ * Local minute-of-day [0, 24*60) for an ISO instant in the given zone.
41910
+ * Returns NaN if the inputs cannot be parsed (caller should drop the item).
41911
+ */
41912
+ function getLocalMinuteOfDay(isoTimestamp, timeZone) {
41913
+ const date = new Date(isoTimestamp);
41914
+ if (Number.isNaN(date.getTime())) return NaN;
41915
+ if (!isValidTimeZone(timeZone)) return NaN;
41916
+ const parts = new Intl.DateTimeFormat("en-GB", {
41917
+ timeZone,
41918
+ hour: "2-digit",
41919
+ minute: "2-digit",
41920
+ hourCycle: "h23"
41921
+ }).formatToParts(date);
41922
+ const hourPart = Number(parts.find((part) => part.type === "hour")?.value);
41923
+ const minute = Number(parts.find((part) => part.type === "minute")?.value);
41924
+ const hour = hourPart === 24 ? 0 : hourPart;
41925
+ if (Number.isNaN(hour) || Number.isNaN(minute)) return NaN;
41926
+ return hour * MINUTES_PER_HOUR + minute;
41927
+ }
41928
+ /**
41929
+ * Local YYYY-MM-DD for an ISO instant in the given zone.
41930
+ * Returns null if the inputs cannot be parsed.
41931
+ */
41932
+ function getLocalDateKey(isoTimestamp, timeZone) {
41933
+ const date = new Date(isoTimestamp);
41934
+ if (Number.isNaN(date.getTime())) return null;
41935
+ if (!isValidTimeZone(timeZone)) return null;
41936
+ const parts = new Intl.DateTimeFormat("en-CA", {
41937
+ timeZone,
41938
+ year: "numeric",
41939
+ month: "2-digit",
41940
+ day: "2-digit"
41941
+ }).formatToParts(date);
41942
+ const year = parts.find((part) => part.type === "year")?.value;
41943
+ const month = parts.find((part) => part.type === "month")?.value;
41944
+ const day = parts.find((part) => part.type === "day")?.value;
41945
+ if (year === void 0 || year.length === 0) return null;
41946
+ if (month === void 0 || month.length === 0) return null;
41947
+ if (day === void 0 || day.length === 0) return null;
41948
+ return `${year}-${month}-${day}`;
41949
+ }
41950
+ const validTimeZoneCache = /* @__PURE__ */ new Map();
41951
+ function isValidTimeZone(timeZone) {
41952
+ if (typeof timeZone !== "string" || timeZone.length === 0) return false;
41953
+ const cached = validTimeZoneCache.get(timeZone);
41954
+ if (cached !== void 0) return cached;
41955
+ try {
41956
+ new Intl.DateTimeFormat("en-US", { timeZone });
41957
+ validTimeZoneCache.set(timeZone, true);
41958
+ return true;
41959
+ } catch {
41960
+ validTimeZoneCache.set(timeZone, false);
41961
+ return false;
41962
+ }
41963
+ }
41964
+ //#endregion
41965
+ //#region ../calendar/src/timetable.ts
41966
+ /**
41967
+ * Timetable model — the package's core surface.
41968
+ *
41969
+ * Generic over `T`. Takes a list of timed items + a viewport; returns a
41970
+ * day-by-day layout with overlap-packed lanes and percent-based positions
41971
+ * plus a diagnostics list for items dropped during processing.
41972
+ *
41973
+ * The function is pure: given identical input (including `now`), it returns
41974
+ * the same output. The model has no opinion on what an item is — pass
41975
+ * accessors.
41976
+ */
41977
+ const DEFAULT_BOUNDS = {
41978
+ minStartHour: 5,
41979
+ maxEndHour: 23,
41980
+ defaultStartHour: 6,
41981
+ defaultEndHour: 21,
41982
+ minHeightPercent: 2.5
41983
+ };
41984
+ function buildTimetableModel$1(input) {
41985
+ const bounds = resolveBounds(input.bounds);
41986
+ const viewportDays = getTimetableDays(input.viewDate, input.view ?? "week", input.startOfWeek);
41987
+ const viewportKeys = new Set(viewportDays.map(formatDateKey));
41988
+ const parsed = parseAllItems(input.items, input);
41989
+ const inViewport = deriveVisibleSegments(parsed.drafts, viewportKeys);
41990
+ const hourRange = resolveHourRange(inViewport, bounds);
41991
+ const laid = layoutCellsByDate(inViewport, hourRange, bounds);
41992
+ return {
41993
+ days: assembleDays(viewportDays, laid.cellsByDate, input.today),
41994
+ hourTicks: buildHourTicks(hourRange.startHour, hourRange.endHour),
41995
+ startHour: hourRange.startHour,
41996
+ endHour: hourRange.endHour,
41997
+ rangeMinutes: (hourRange.endHour - hourRange.startHour) * 60,
41998
+ hasItems: inViewport.length > 0,
41999
+ diagnostics: [...parsed.diagnostics, ...laid.diagnostics]
42000
+ };
42001
+ }
42002
+ function parseAllItems(items, accessors) {
42003
+ const drafts = [];
42004
+ const diagnostics = [];
42005
+ for (const item of items) {
42006
+ const result = parseDraft(item, accessors);
42007
+ if (result.ok) drafts.push(result.draft);
42008
+ else diagnostics.push({
42009
+ item,
42010
+ reason: result.reason
42011
+ });
42012
+ }
42013
+ return {
42014
+ drafts,
42015
+ diagnostics
42016
+ };
42017
+ }
42018
+ function layoutCellsByDate(segments, hourRange, bounds) {
42019
+ const rangeMinutes = (hourRange.endHour - hourRange.startHour) * 60;
42020
+ const rangeStartMinutes = hourRange.startHour * 60;
42021
+ const rangeEndMinutes = hourRange.endHour * 60;
42022
+ const segmentsByDate = groupSegmentsByDate(segments);
42023
+ const cellsByDate = /* @__PURE__ */ new Map();
42024
+ const diagnostics = [];
42025
+ for (const [dateKey, daySegments] of segmentsByDate) {
42026
+ const clipped = [];
42027
+ for (const segment of daySegments) {
42028
+ const result = clipDraft(segment, rangeStartMinutes, rangeEndMinutes);
42029
+ if (result.ok) clipped.push(result.clipped);
42030
+ else diagnostics.push({
42031
+ item: segment.draft.item,
42032
+ reason: result.reason
42033
+ });
42034
+ }
42035
+ const laneResults = assignLanes(clipped.map((entry) => ({
42036
+ start: entry.visibleStart,
42037
+ end: entry.visibleEnd,
42038
+ orderKey: entry.segment.draft.orderKey
42039
+ })));
42040
+ cellsByDate.set(dateKey, clipped.map((entry, idx) => buildCell(entry, laneResults[idx] ?? {
42041
+ laneIndex: 0,
42042
+ laneCount: 1
42043
+ }, rangeStartMinutes, rangeMinutes, bounds.minHeightPercent)));
42044
+ }
42045
+ return {
42046
+ cellsByDate,
42047
+ diagnostics
42048
+ };
42049
+ }
42050
+ function assembleDays(viewportDays, cellsByDate, today) {
42051
+ return viewportDays.map((date) => {
42052
+ const dateKey = formatDateKey(date);
42053
+ return {
42054
+ date,
42055
+ dateKey,
42056
+ isToday: isSameCalendarDay(date, today),
42057
+ cells: cellsByDate.get(dateKey) ?? []
42058
+ };
42059
+ });
42060
+ }
42061
+ function parseDraft(item, accessors) {
42062
+ const startsAtIso = accessors.getStartsAt(item);
42063
+ const endsAtIso = accessors.getEndsAt(item);
42064
+ const timeZone = accessors.getTimeZone(item);
42065
+ const startsAtMinutes = getLocalMinuteOfDay(startsAtIso, timeZone);
42066
+ if (Number.isNaN(startsAtMinutes)) return {
42067
+ ok: false,
42068
+ reason: "invalid-start"
42069
+ };
42070
+ const startDateKey = getLocalDateKey(startsAtIso, timeZone);
42071
+ if (startDateKey == null) return {
42072
+ ok: false,
42073
+ reason: "invalid-timezone"
42074
+ };
42075
+ const endDateKey = getLocalDateKey(endsAtIso, timeZone);
42076
+ if (endDateKey == null) return {
42077
+ ok: false,
42078
+ reason: "invalid-end"
42079
+ };
42080
+ const endsAtMinutesRaw = getLocalMinuteOfDay(endsAtIso, timeZone);
42081
+ if (Number.isNaN(endsAtMinutesRaw)) return {
42082
+ ok: false,
42083
+ reason: "invalid-end"
42084
+ };
42085
+ const spansMultipleDays = startDateKey < endDateKey;
42086
+ const endsAtMinutes = spansMultipleDays || endsAtMinutesRaw > startsAtMinutes ? endsAtMinutesRaw : Math.min(1440, startsAtMinutes + 60);
42087
+ if (!spansMultipleDays && endsAtMinutes <= startsAtMinutes) return {
42088
+ ok: false,
42089
+ reason: "non-positive-duration"
42090
+ };
42091
+ return {
42092
+ ok: true,
42093
+ draft: {
42094
+ item,
42095
+ startDateKey,
42096
+ endDateKey,
42097
+ startsAtMinutes,
42098
+ endsAtMinutes,
42099
+ color: accessors.getColor?.(item) ?? null,
42100
+ orderKey: accessors.getOrderKey?.(item) ?? accessors.getId(item)
42101
+ }
42102
+ };
42103
+ }
42104
+ function deriveVisibleSegments(drafts, viewportKeys) {
42105
+ const segments = [];
42106
+ for (const draft of drafts) for (const dateKey of enumerateDateKeys(draft.startDateKey, draft.endDateKey)) {
42107
+ if (!viewportKeys.has(dateKey)) continue;
42108
+ const isStartDay = dateKey === draft.startDateKey;
42109
+ const isEndDay = dateKey === draft.endDateKey;
42110
+ const startsAtMinutes = isStartDay ? draft.startsAtMinutes : 0;
42111
+ const endsAtMinutes = isEndDay ? draft.endsAtMinutes : 1440;
42112
+ if (endsAtMinutes <= startsAtMinutes) continue;
42113
+ segments.push({
42114
+ draft,
42115
+ dateKey,
42116
+ startsAtMinutes,
42117
+ endsAtMinutes,
42118
+ continuesBefore: !isStartDay,
42119
+ continuesAfter: !isEndDay
42120
+ });
42121
+ }
42122
+ return segments;
42123
+ }
42124
+ function clipDraft(segment, rangeStartMinutes, rangeEndMinutes) {
42125
+ const visibleStart = Math.max(rangeStartMinutes, segment.startsAtMinutes);
42126
+ const visibleEnd = Math.min(rangeEndMinutes, segment.endsAtMinutes);
42127
+ if (visibleEnd <= visibleStart) return {
42128
+ ok: false,
42129
+ reason: "out-of-visible-hours"
42130
+ };
42131
+ return {
42132
+ ok: true,
42133
+ clipped: {
42134
+ segment,
42135
+ visibleStart,
42136
+ visibleEnd
42137
+ }
42138
+ };
42139
+ }
42140
+ function buildCell(entry, lane, rangeStartMinutes, rangeMinutes, minHeightPercent) {
42141
+ const heightPercent = Math.max(minHeightPercent, toRangePercent(entry.visibleEnd - entry.visibleStart, rangeMinutes));
42142
+ const rawTopPercent = toRangePercent(entry.visibleStart - rangeStartMinutes, rangeMinutes);
42143
+ const topPercent = Math.min(rawTopPercent, 100 - heightPercent);
42144
+ return {
42145
+ item: entry.segment.draft.item,
42146
+ dateKey: entry.segment.dateKey,
42147
+ startsAtMinutes: entry.segment.startsAtMinutes,
42148
+ endsAtMinutes: entry.segment.endsAtMinutes,
42149
+ visibleStartsAtMinutes: entry.visibleStart,
42150
+ visibleEndsAtMinutes: entry.visibleEnd,
42151
+ topPercent,
42152
+ heightPercent,
42153
+ isClippedStart: entry.segment.continuesBefore || entry.segment.startsAtMinutes < entry.visibleStart,
42154
+ isClippedEnd: entry.segment.continuesAfter || entry.segment.endsAtMinutes > entry.visibleEnd,
42155
+ laneIndex: lane.laneIndex,
42156
+ laneCount: lane.laneCount,
42157
+ color: entry.segment.draft.color
42158
+ };
42159
+ }
42160
+ function resolveBounds(bounds) {
42161
+ return {
42162
+ minStartHour: bounds?.minStartHour ?? DEFAULT_BOUNDS.minStartHour,
42163
+ maxEndHour: bounds?.maxEndHour ?? DEFAULT_BOUNDS.maxEndHour,
42164
+ defaultStartHour: bounds?.defaultStartHour ?? DEFAULT_BOUNDS.defaultStartHour,
42165
+ defaultEndHour: bounds?.defaultEndHour ?? DEFAULT_BOUNDS.defaultEndHour,
42166
+ minHeightPercent: bounds?.minHeightPercent ?? DEFAULT_BOUNDS.minHeightPercent
42167
+ };
42168
+ }
42169
+ function resolveHourRange(segments, bounds) {
42170
+ if (segments.length === 0) return {
42171
+ startHour: bounds.defaultStartHour,
42172
+ endHour: bounds.defaultEndHour
42173
+ };
42174
+ const earliestStart = Math.min(...segments.map((segment) => segment.startsAtMinutes));
42175
+ const latestEnd = Math.max(...segments.map((segment) => segment.endsAtMinutes));
42176
+ const startHour = Math.max(bounds.minStartHour, Math.floor(earliestStart / 60));
42177
+ const endHour = Math.min(bounds.maxEndHour, Math.ceil(latestEnd / 60));
42178
+ return {
42179
+ startHour,
42180
+ endHour: Math.max(startHour + 1, endHour)
42181
+ };
42182
+ }
42183
+ function buildHourTicks(startHour, endHour) {
42184
+ const ticks = [];
42185
+ for (let hour = startHour; hour <= endHour; hour += 1) ticks.push(hour);
42186
+ return ticks;
42187
+ }
42188
+ function groupSegmentsByDate(segments) {
42189
+ const grouped = /* @__PURE__ */ new Map();
42190
+ for (const segment of segments) {
42191
+ const existing = grouped.get(segment.dateKey) ?? [];
42192
+ existing.push(segment);
42193
+ grouped.set(segment.dateKey, existing);
42194
+ }
42195
+ return grouped;
42196
+ }
42197
+ function toRangePercent(minutes, rangeMinutes) {
42198
+ if (rangeMinutes <= 0) return 0;
42199
+ return minutes / rangeMinutes * 100;
42200
+ }
42201
+ function getTimetableDays(viewDate, view, startOfWeek) {
42202
+ if (view === "day") return [viewDate];
42203
+ return getWeekCalendarDays(viewDate, startOfWeek);
42204
+ }
42205
+ function enumerateDateKeys(startDateKey, endDateKey) {
42206
+ if (startDateKey > endDateKey) throw new Error(`Invalid timetable date range: ${startDateKey} is after ${endDateKey}`);
42207
+ const start = parseDateKey(startDateKey);
42208
+ parseDateKey(endDateKey);
42209
+ const keys = [];
42210
+ let cursor = start;
42211
+ while (formatDateKey(cursor) <= endDateKey) {
42212
+ keys.push(formatDateKey(cursor));
42213
+ cursor = addDays(cursor, 1);
42214
+ }
42215
+ return keys;
42216
+ }
42217
+ function parseDateKey(dateKey) {
42218
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
42219
+ if (match === null) throw new Error(`Invalid timetable date key: ${dateKey}`);
42220
+ const [, year, month, day] = match;
42221
+ return {
42222
+ year: Number(year),
42223
+ month: Number(month),
42224
+ day: Number(day)
42225
+ };
42226
+ }
42227
+ //#endregion
42228
+ //#region ../calendar/src/group.ts
42229
+ /**
42230
+ * Group items by their local date (YYYY-MM-DD in the item's own timezone).
42231
+ *
42232
+ * Generic over `T`. Items with un-parseable startsAt or invalid timezone are
42233
+ * silently dropped — call sites that need diagnostics should validate upstream.
42234
+ *
42235
+ * The output map preserves insertion order of dates (ES2015 spec) and the
42236
+ * relative order of items within each date matches input order.
42237
+ */
42238
+ function groupItemsByDate(items, accessors) {
42239
+ const grouped = /* @__PURE__ */ new Map();
42240
+ for (const item of items) {
42241
+ const dateKey = getLocalDateKey(accessors.getStartsAt(item), accessors.getTimeZone(item));
42242
+ if (dateKey == null) continue;
42243
+ const existing = grouped.get(dateKey);
42244
+ if (existing !== void 0) existing.push(item);
42245
+ else grouped.set(dateKey, [item]);
42246
+ }
42247
+ return grouped;
42248
+ }
42249
+ //#endregion
42250
+ //#region ../blocks/src/system/runtime/nodes/events/domain/dates.ts
42251
+ /**
42252
+ * Date and time presentation for events: the single place an event's
42253
+ * timestamps become strings or date buckets. Calendar-grid primitives are
42254
+ * re-exported from @riverbankcms/calendar so block code has one import.
42255
+ */
42256
+ /**
42257
+ * Format date for display.
42258
+ * Returns: "Monday, January 15, 2025"
42259
+ */
42260
+ function formatDate$2(isoTimestamp, options) {
42261
+ return formatBookingDate(toScheduledBookingDateTime({
42262
+ startsAt: isoTimestamp,
42263
+ timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
42264
+ locale: options?.locale ?? "en-US"
42265
+ }), {
42266
+ weekday: "long",
42267
+ year: "numeric",
42268
+ month: "long",
42269
+ day: "numeric"
42270
+ });
42271
+ }
42272
+ /**
42273
+ * Format time for display.
42274
+ * Returns: "10:00 AM"
42275
+ */
42276
+ function formatTime(isoTimestamp, options) {
42277
+ return formatBookingTime(toScheduledBookingDateTime({
42278
+ startsAt: isoTimestamp,
42279
+ timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
42280
+ locale: options?.locale ?? "en-US"
42281
+ }), {
42282
+ hour: "numeric",
42283
+ minute: "2-digit",
42284
+ hour12: true
42285
+ });
42286
+ }
42287
+ function formatEventDateParts(args) {
42288
+ const scheduledRange = toScheduledBookingDateRange({
42289
+ startsAt: args.startsAt,
42290
+ endsAt: args.endsAt,
42291
+ timeZone: toBookingTimeZone(args.timeZone ?? "UTC"),
42292
+ locale: args.locale ?? "en-US"
42293
+ });
42294
+ const { date, time: start } = formatBookingDateParts(scheduledRange, { dateFormat: {
42295
+ weekday: "long",
42296
+ year: "numeric",
42297
+ month: "long",
42298
+ day: "numeric"
42299
+ } });
42300
+ return {
42301
+ date,
42302
+ timeRange: `${start} – ${formatBookingTime(toScheduledBookingDateTime({
42303
+ startsAt: args.endsAt,
42304
+ timeZone: scheduledRange.timeZone,
42305
+ locale: args.locale ?? "en-US"
42306
+ }))}`
42307
+ };
42308
+ }
42309
+ function formatEventCompactDateRail(isoTimestamp, options) {
42310
+ const dateTime = toScheduledBookingDateTime({
42311
+ startsAt: isoTimestamp,
42312
+ timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
42313
+ locale: options?.locale ?? "en-US"
42314
+ });
42315
+ const weekday = formatBookingDate(dateTime, { weekday: "short" }).toLocaleUpperCase(options?.locale ?? "en-US");
42316
+ const month = formatBookingDate(dateTime, { month: "short" }).toLocaleUpperCase(options?.locale ?? "en-US");
42317
+ return {
42318
+ weekday,
42319
+ day: formatBookingDate(dateTime, { day: "numeric" }),
42320
+ month,
42321
+ label: formatBookingDate(dateTime, {
42322
+ weekday: "long",
42323
+ year: "numeric",
42324
+ month: "long",
42325
+ day: "numeric"
42326
+ })
42327
+ };
42328
+ }
42329
+ /**
42330
+ * Group events by their local date (YYYY-MM-DD key in each event's own
42331
+ * timezone). Thin domain wrapper over `groupItemsByDate` from the calendar
42332
+ * package; preserves the historical `Map<string, PublicEvent[]>` shape.
42333
+ */
42334
+ function groupEventsByDate(events, calendarTimeZone) {
42335
+ const grouped = groupItemsByDate(events, {
42336
+ getStartsAt: (event) => event.startsAt,
42337
+ getTimeZone: () => calendarTimeZone
42338
+ });
42339
+ const map = /* @__PURE__ */ new Map();
42340
+ for (const [dateKey, bucket] of grouped) map.set(dateKey, [...bucket]);
42341
+ return map;
42342
+ }
42343
+ //#endregion
42344
+ //#region ../blocks/src/system/runtime/nodes/events/domain/people.ts
42345
+ function formatTeamMemberNames(names) {
42346
+ const normalized = names.map((name) => name.trim()).filter(Boolean);
42347
+ if (normalized.length === 0) return null;
42348
+ const [firstName, secondName] = normalized;
42349
+ if (!firstName) return null;
42350
+ if (normalized.length === 1) return firstName;
42351
+ if (normalized.length === 2 && secondName) return `${firstName} and ${secondName}`;
42352
+ const finalName = normalized[normalized.length - 1] ?? "";
42353
+ return `${normalized.slice(0, -1).join(", ")}, and ${finalName}`;
42354
+ }
42355
+ function formatEventTeacherLine(event) {
42356
+ const names = formatTeamMemberNames((event.teamMembers ?? []).map((teamMember) => teamMember.name));
42357
+ return names ? `with ${names}` : null;
42358
+ }
42359
+ //#endregion
42360
+ //#region ../blocks/src/system/runtime/nodes/events/domain/pricing.ts
41122
42361
  const DEFAULT_EVENT_CURRENCY = "USD";
41123
42362
  function resolveRuntimeEventPricing(event) {
41124
42363
  if (isPublicEventPricing(event.pricing)) return event.pricing;
@@ -41285,6 +42524,17 @@ function readCoursePriceDisplay(value, currency) {
41285
42524
  function readAmount(value) {
41286
42525
  return typeof value === "number" && Number.isFinite(value) ? value : null;
41287
42526
  }
42527
+ /**
42528
+ * Format event price for display.
42529
+ * @returns "Free", "£25", "£12 - £25", "Free / £12 - £25", "Course (From £40)", etc.
42530
+ *
42531
+ * Events render compact amounts (no trailing ".00"); the actual ADT→string
42532
+ * mapping lives in `formatBookingPriceDisplay` in @riverbankcms/core so all
42533
+ * surfaces stay consistent.
42534
+ */
42535
+ function formatEventPrice(event) {
42536
+ return formatEventPricing(resolveRuntimeEventPricing(event), { decimals: "auto" });
42537
+ }
41288
42538
  const eventCalendarDisplayModeValues = [
41289
42539
  "combined",
41290
42540
  "calendar",
@@ -41809,7 +43059,7 @@ const eventRegistrationBlockDefinition = {
41809
43059
  {
41810
43060
  id: "showCapacity",
41811
43061
  type: "boolean",
41812
- label: "Show available spots",
43062
+ label: "Show available places",
41813
43063
  description: "Display remaining capacity",
41814
43064
  required: false,
41815
43065
  defaultValue: true,
@@ -42346,599 +43596,34 @@ const eventSpotlightBlockDefinition = {
42346
43596
  showCapacity: { $bind: { from: "content.showCapacity" } },
42347
43597
  emptyMessage: { $bind: { from: "content.emptyMessage" } }
42348
43598
  })], { gap: "lg" }),
42349
- baseClass: "",
42350
- spacing: "comfortable"
42351
- })]
42352
- }),
42353
- dataSchemas: { events: publicEventsArraySchema.optional() },
42354
- dataLoaders: { events: {
42355
- endpoint: "listPublicEvents",
42356
- params: {
42357
- siteId: { $bind: { from: "$root.siteId" } },
42358
- limit: { $bind: {
42359
- from: "content.maxEvents",
42360
- fallback: "3"
42361
- } },
42362
- stage: { $bind: {
42363
- from: "$root.previewStage",
42364
- fallback: "published"
42365
- } },
42366
- detailLevel: "listing",
42367
- readProfile: "spotlight"
42368
- },
42369
- mode: "server"
42370
- } }
42371
- };
42372
- //#endregion
42373
- //#region ../calendar/src/grid.ts
42374
- const DAY_NAMES = [
42375
- "Sun",
42376
- "Mon",
42377
- "Tue",
42378
- "Wed",
42379
- "Thu",
42380
- "Fri",
42381
- "Sat"
42382
- ];
42383
- const MONTH_NAMES = [
42384
- "January",
42385
- "February",
42386
- "March",
42387
- "April",
42388
- "May",
42389
- "June",
42390
- "July",
42391
- "August",
42392
- "September",
42393
- "October",
42394
- "November",
42395
- "December"
42396
- ];
42397
- function formatDateKey(date) {
42398
- return `${String(date.year).padStart(4, "0")}-${String(date.month).padStart(2, "0")}-${String(date.day).padStart(2, "0")}`;
42399
- }
42400
- function isToday(date, today) {
42401
- return isSameCalendarDay(date, today);
42402
- }
42403
- function isSameCalendarDay(left, right) {
42404
- return left.year === right.year && left.month === right.month && left.day === right.day;
42405
- }
42406
- function addDays(date, days) {
42407
- const result = new Date(Date.UTC(date.year, date.month - 1, date.day + days, 12));
42408
- return {
42409
- year: result.getUTCFullYear(),
42410
- month: result.getUTCMonth() + 1,
42411
- day: result.getUTCDate()
42412
- };
42413
- }
42414
- function getMonthCalendarDays(year, month, startOfWeek) {
42415
- const firstDay = {
42416
- year,
42417
- month,
42418
- day: 1
42419
- };
42420
- const dayOfWeek = getDayOfWeek(firstDay);
42421
- const gridStart = addDays(firstDay, -(startOfWeek === "monday" ? dayOfWeek === 0 ? 6 : dayOfWeek - 1 : dayOfWeek));
42422
- return Array.from({ length: 42 }, (_, index) => addDays(gridStart, index));
42423
- }
42424
- function getWeekCalendarDays(baseDate, startOfWeek) {
42425
- const dayOfWeek = getDayOfWeek(baseDate);
42426
- const weekStart = addDays(baseDate, -(startOfWeek === "monday" ? dayOfWeek === 0 ? 6 : dayOfWeek - 1 : dayOfWeek));
42427
- return Array.from({ length: 7 }, (_, index) => addDays(weekStart, index));
42428
- }
42429
- function getDayOfWeek(date) {
42430
- return new Date(Date.UTC(date.year, date.month - 1, date.day, 12)).getUTCDay();
42431
- }
42432
- function getWeekCalendarDayTuple(baseDate, startOfWeek) {
42433
- const days = getWeekCalendarDays(baseDate, startOfWeek);
42434
- assertWeekCalendarDays(days);
42435
- return days;
42436
- }
42437
- function assertWeekCalendarDays(days) {
42438
- if (days.length !== 7) throw new Error("Expected exactly 7 calendar days");
42439
- }
42440
- function getOrderedDayNames(startOfWeek, dayNames = DAY_NAMES) {
42441
- if (startOfWeek === "monday") {
42442
- const sunday = dayNames[0] ?? "Sun";
42443
- return [...dayNames.slice(1), sunday];
42444
- }
42445
- return [...dayNames];
42446
- }
42447
- function formatCalendarTitle(viewDate, view, days, monthNames = MONTH_NAMES) {
42448
- if (view === "week") {
42449
- const weekStart = days[0];
42450
- const weekEnd = days[6];
42451
- if (weekStart === void 0 || weekEnd === void 0) return `${monthNames[viewDate.month - 1] ?? ""} ${viewDate.year}`;
42452
- const startMonth = monthNames[weekStart.month - 1] ?? "";
42453
- const endMonth = monthNames[weekEnd.month - 1] ?? "";
42454
- if (weekStart.month === weekEnd.month) return `${startMonth} ${weekStart.day} - ${weekEnd.day}, ${weekEnd.year}`;
42455
- return `${startMonth} ${weekStart.day} - ${endMonth} ${weekEnd.day}, ${weekEnd.year}`;
42456
- }
42457
- return `${monthNames[viewDate.month - 1] ?? ""} ${viewDate.year}`;
42458
- }
42459
- function buildCalendarViewport(input) {
42460
- const monthNames = input.monthNames ?? MONTH_NAMES;
42461
- if (input.view === "week") {
42462
- const days = getWeekCalendarDayTuple(input.anchorDate, input.startOfWeek);
42463
- return {
42464
- kind: "week",
42465
- anchorDate: input.anchorDate,
42466
- days,
42467
- title: formatCalendarTitle(input.anchorDate, "week", days, monthNames)
42468
- };
42469
- }
42470
- const days = getMonthCalendarDays(input.anchorDate.year, input.anchorDate.month, input.startOfWeek);
42471
- return {
42472
- kind: "month",
42473
- anchorDate: input.anchorDate,
42474
- days,
42475
- title: formatCalendarTitle(input.anchorDate, "month", days, monthNames)
42476
- };
42477
- }
42478
- //#endregion
42479
- //#region ../calendar/src/lane-packing.ts
42480
- /**
42481
- * Assign lanes to a list of intervals.
42482
- *
42483
- * Returns one result per input in the same order as the input. Internally
42484
- * sorts a copy by `(start, end, orderKey)` to ensure determinism, then
42485
- * permutes results back to input order.
42486
- */
42487
- function assignLanes(intervals) {
42488
- if (intervals.length === 0) return [];
42489
- const indexed = intervals.map((interval, index) => ({
42490
- interval,
42491
- index
42492
- }));
42493
- indexed.sort((left, right) => left.interval.start - right.interval.start || left.interval.end - right.interval.end || left.interval.orderKey.localeCompare(right.interval.orderKey));
42494
- const results = new Array(intervals.length);
42495
- let cluster = [];
42496
- let activeLaneEnds = [];
42497
- let clusterMaxEnd = -Infinity;
42498
- const closeCluster = () => {
42499
- if (cluster.length === 0) return;
42500
- const laneCount = Math.max(1, cluster.reduce((max, entry) => Math.max(max, entry.laneIndex + 1), 0));
42501
- for (const entry of cluster) results[entry.index] = {
42502
- laneIndex: entry.laneIndex,
42503
- laneCount
42504
- };
42505
- cluster = [];
42506
- activeLaneEnds = [];
42507
- clusterMaxEnd = -Infinity;
42508
- };
42509
- for (const { interval, index } of indexed) {
42510
- if (interval.start >= clusterMaxEnd) closeCluster();
42511
- const laneIndex = firstAvailableLane(activeLaneEnds, interval.start);
42512
- activeLaneEnds[laneIndex] = interval.end;
42513
- clusterMaxEnd = Math.max(clusterMaxEnd, interval.end);
42514
- cluster.push({
42515
- index,
42516
- laneIndex
42517
- });
42518
- }
42519
- closeCluster();
42520
- return results;
42521
- }
42522
- function firstAvailableLane(activeLaneEnds, start) {
42523
- const laneIndex = activeLaneEnds.findIndex((end) => end <= start);
42524
- return laneIndex >= 0 ? laneIndex : activeLaneEnds.length;
42525
- }
42526
- //#endregion
42527
- //#region ../calendar/src/timezone.ts
42528
- /**
42529
- * Timezone boundary helpers — internal.
42530
- *
42531
- * Both functions read an ISO instant in a given IANA timezone and project it
42532
- * onto the wall clock of that zone. They use Intl.DateTimeFormat with
42533
- * hourCycle: 'h23' to avoid 12/24-hour ambiguity. Used internally by the
42534
- * timetable model and `groupItemsByDate`; not exported from the package's
42535
- * public surface.
42536
- */
42537
- const MINUTES_PER_HOUR = 60;
42538
- /**
42539
- * Local minute-of-day [0, 24*60) for an ISO instant in the given zone.
42540
- * Returns NaN if the inputs cannot be parsed (caller should drop the item).
42541
- */
42542
- function getLocalMinuteOfDay(isoTimestamp, timeZone) {
42543
- const date = new Date(isoTimestamp);
42544
- if (Number.isNaN(date.getTime())) return NaN;
42545
- if (!isValidTimeZone(timeZone)) return NaN;
42546
- const parts = new Intl.DateTimeFormat("en-GB", {
42547
- timeZone,
42548
- hour: "2-digit",
42549
- minute: "2-digit",
42550
- hourCycle: "h23"
42551
- }).formatToParts(date);
42552
- const hourPart = Number(parts.find((part) => part.type === "hour")?.value);
42553
- const minute = Number(parts.find((part) => part.type === "minute")?.value);
42554
- const hour = hourPart === 24 ? 0 : hourPart;
42555
- if (Number.isNaN(hour) || Number.isNaN(minute)) return NaN;
42556
- return hour * MINUTES_PER_HOUR + minute;
42557
- }
42558
- /**
42559
- * Local YYYY-MM-DD for an ISO instant in the given zone.
42560
- * Returns null if the inputs cannot be parsed.
42561
- */
42562
- function getLocalDateKey(isoTimestamp, timeZone) {
42563
- const date = new Date(isoTimestamp);
42564
- if (Number.isNaN(date.getTime())) return null;
42565
- if (!isValidTimeZone(timeZone)) return null;
42566
- const parts = new Intl.DateTimeFormat("en-CA", {
42567
- timeZone,
42568
- year: "numeric",
42569
- month: "2-digit",
42570
- day: "2-digit"
42571
- }).formatToParts(date);
42572
- const year = parts.find((part) => part.type === "year")?.value;
42573
- const month = parts.find((part) => part.type === "month")?.value;
42574
- const day = parts.find((part) => part.type === "day")?.value;
42575
- if (year === void 0 || year.length === 0) return null;
42576
- if (month === void 0 || month.length === 0) return null;
42577
- if (day === void 0 || day.length === 0) return null;
42578
- return `${year}-${month}-${day}`;
42579
- }
42580
- const validTimeZoneCache = /* @__PURE__ */ new Map();
42581
- function isValidTimeZone(timeZone) {
42582
- if (typeof timeZone !== "string" || timeZone.length === 0) return false;
42583
- const cached = validTimeZoneCache.get(timeZone);
42584
- if (cached !== void 0) return cached;
42585
- try {
42586
- new Intl.DateTimeFormat("en-US", { timeZone });
42587
- validTimeZoneCache.set(timeZone, true);
42588
- return true;
42589
- } catch {
42590
- validTimeZoneCache.set(timeZone, false);
42591
- return false;
42592
- }
42593
- }
42594
- //#endregion
42595
- //#region ../calendar/src/timetable.ts
42596
- /**
42597
- * Timetable model — the package's core surface.
42598
- *
42599
- * Generic over `T`. Takes a list of timed items + a viewport; returns a
42600
- * day-by-day layout with overlap-packed lanes and percent-based positions
42601
- * plus a diagnostics list for items dropped during processing.
42602
- *
42603
- * The function is pure: given identical input (including `now`), it returns
42604
- * the same output. The model has no opinion on what an item is — pass
42605
- * accessors.
42606
- */
42607
- const DEFAULT_BOUNDS = {
42608
- minStartHour: 5,
42609
- maxEndHour: 23,
42610
- defaultStartHour: 6,
42611
- defaultEndHour: 21,
42612
- minHeightPercent: 2.5
43599
+ baseClass: "",
43600
+ spacing: "comfortable"
43601
+ })]
43602
+ }),
43603
+ dataSchemas: { events: publicEventsArraySchema.optional() },
43604
+ dataLoaders: { events: {
43605
+ endpoint: "listPublicEvents",
43606
+ params: {
43607
+ siteId: { $bind: { from: "$root.siteId" } },
43608
+ limit: { $bind: {
43609
+ from: "content.maxEvents",
43610
+ fallback: "3"
43611
+ } },
43612
+ stage: { $bind: {
43613
+ from: "$root.previewStage",
43614
+ fallback: "published"
43615
+ } },
43616
+ detailLevel: "listing",
43617
+ readProfile: "spotlight"
43618
+ },
43619
+ mode: "server"
43620
+ } }
42613
43621
  };
42614
- function buildTimetableModel$1(input) {
42615
- const bounds = resolveBounds(input.bounds);
42616
- const viewportDays = getTimetableDays(input.viewDate, input.view ?? "week", input.startOfWeek);
42617
- const viewportKeys = new Set(viewportDays.map(formatDateKey));
42618
- const parsed = parseAllItems(input.items, input);
42619
- const inViewport = deriveVisibleSegments(parsed.drafts, viewportKeys);
42620
- const hourRange = resolveHourRange(inViewport, bounds);
42621
- const laid = layoutCellsByDate(inViewport, hourRange, bounds);
42622
- return {
42623
- days: assembleDays(viewportDays, laid.cellsByDate, input.today),
42624
- hourTicks: buildHourTicks(hourRange.startHour, hourRange.endHour),
42625
- startHour: hourRange.startHour,
42626
- endHour: hourRange.endHour,
42627
- rangeMinutes: (hourRange.endHour - hourRange.startHour) * 60,
42628
- hasItems: inViewport.length > 0,
42629
- diagnostics: [...parsed.diagnostics, ...laid.diagnostics]
42630
- };
42631
- }
42632
- function parseAllItems(items, accessors) {
42633
- const drafts = [];
42634
- const diagnostics = [];
42635
- for (const item of items) {
42636
- const result = parseDraft(item, accessors);
42637
- if (result.ok) drafts.push(result.draft);
42638
- else diagnostics.push({
42639
- item,
42640
- reason: result.reason
42641
- });
42642
- }
42643
- return {
42644
- drafts,
42645
- diagnostics
42646
- };
42647
- }
42648
- function layoutCellsByDate(segments, hourRange, bounds) {
42649
- const rangeMinutes = (hourRange.endHour - hourRange.startHour) * 60;
42650
- const rangeStartMinutes = hourRange.startHour * 60;
42651
- const rangeEndMinutes = hourRange.endHour * 60;
42652
- const segmentsByDate = groupSegmentsByDate(segments);
42653
- const cellsByDate = /* @__PURE__ */ new Map();
42654
- const diagnostics = [];
42655
- for (const [dateKey, daySegments] of segmentsByDate) {
42656
- const clipped = [];
42657
- for (const segment of daySegments) {
42658
- const result = clipDraft(segment, rangeStartMinutes, rangeEndMinutes);
42659
- if (result.ok) clipped.push(result.clipped);
42660
- else diagnostics.push({
42661
- item: segment.draft.item,
42662
- reason: result.reason
42663
- });
42664
- }
42665
- const laneResults = assignLanes(clipped.map((entry) => ({
42666
- start: entry.visibleStart,
42667
- end: entry.visibleEnd,
42668
- orderKey: entry.segment.draft.orderKey
42669
- })));
42670
- cellsByDate.set(dateKey, clipped.map((entry, idx) => buildCell(entry, laneResults[idx] ?? {
42671
- laneIndex: 0,
42672
- laneCount: 1
42673
- }, rangeStartMinutes, rangeMinutes, bounds.minHeightPercent)));
42674
- }
42675
- return {
42676
- cellsByDate,
42677
- diagnostics
42678
- };
42679
- }
42680
- function assembleDays(viewportDays, cellsByDate, today) {
42681
- return viewportDays.map((date) => {
42682
- const dateKey = formatDateKey(date);
42683
- return {
42684
- date,
42685
- dateKey,
42686
- isToday: isSameCalendarDay(date, today),
42687
- cells: cellsByDate.get(dateKey) ?? []
42688
- };
42689
- });
42690
- }
42691
- function parseDraft(item, accessors) {
42692
- const startsAtIso = accessors.getStartsAt(item);
42693
- const endsAtIso = accessors.getEndsAt(item);
42694
- const timeZone = accessors.getTimeZone(item);
42695
- const startsAtMinutes = getLocalMinuteOfDay(startsAtIso, timeZone);
42696
- if (Number.isNaN(startsAtMinutes)) return {
42697
- ok: false,
42698
- reason: "invalid-start"
42699
- };
42700
- const startDateKey = getLocalDateKey(startsAtIso, timeZone);
42701
- if (startDateKey == null) return {
42702
- ok: false,
42703
- reason: "invalid-timezone"
42704
- };
42705
- const endDateKey = getLocalDateKey(endsAtIso, timeZone);
42706
- if (endDateKey == null) return {
42707
- ok: false,
42708
- reason: "invalid-end"
42709
- };
42710
- const endsAtMinutesRaw = getLocalMinuteOfDay(endsAtIso, timeZone);
42711
- if (Number.isNaN(endsAtMinutesRaw)) return {
42712
- ok: false,
42713
- reason: "invalid-end"
42714
- };
42715
- const spansMultipleDays = startDateKey < endDateKey;
42716
- const endsAtMinutes = spansMultipleDays || endsAtMinutesRaw > startsAtMinutes ? endsAtMinutesRaw : Math.min(1440, startsAtMinutes + 60);
42717
- if (!spansMultipleDays && endsAtMinutes <= startsAtMinutes) return {
42718
- ok: false,
42719
- reason: "non-positive-duration"
42720
- };
42721
- return {
42722
- ok: true,
42723
- draft: {
42724
- item,
42725
- startDateKey,
42726
- endDateKey,
42727
- startsAtMinutes,
42728
- endsAtMinutes,
42729
- color: accessors.getColor?.(item) ?? null,
42730
- orderKey: accessors.getOrderKey?.(item) ?? accessors.getId(item)
42731
- }
42732
- };
42733
- }
42734
- function deriveVisibleSegments(drafts, viewportKeys) {
42735
- const segments = [];
42736
- for (const draft of drafts) for (const dateKey of enumerateDateKeys(draft.startDateKey, draft.endDateKey)) {
42737
- if (!viewportKeys.has(dateKey)) continue;
42738
- const isStartDay = dateKey === draft.startDateKey;
42739
- const isEndDay = dateKey === draft.endDateKey;
42740
- const startsAtMinutes = isStartDay ? draft.startsAtMinutes : 0;
42741
- const endsAtMinutes = isEndDay ? draft.endsAtMinutes : 1440;
42742
- if (endsAtMinutes <= startsAtMinutes) continue;
42743
- segments.push({
42744
- draft,
42745
- dateKey,
42746
- startsAtMinutes,
42747
- endsAtMinutes,
42748
- continuesBefore: !isStartDay,
42749
- continuesAfter: !isEndDay
42750
- });
42751
- }
42752
- return segments;
42753
- }
42754
- function clipDraft(segment, rangeStartMinutes, rangeEndMinutes) {
42755
- const visibleStart = Math.max(rangeStartMinutes, segment.startsAtMinutes);
42756
- const visibleEnd = Math.min(rangeEndMinutes, segment.endsAtMinutes);
42757
- if (visibleEnd <= visibleStart) return {
42758
- ok: false,
42759
- reason: "out-of-visible-hours"
42760
- };
42761
- return {
42762
- ok: true,
42763
- clipped: {
42764
- segment,
42765
- visibleStart,
42766
- visibleEnd
42767
- }
42768
- };
42769
- }
42770
- function buildCell(entry, lane, rangeStartMinutes, rangeMinutes, minHeightPercent) {
42771
- const heightPercent = Math.max(minHeightPercent, toRangePercent(entry.visibleEnd - entry.visibleStart, rangeMinutes));
42772
- const rawTopPercent = toRangePercent(entry.visibleStart - rangeStartMinutes, rangeMinutes);
42773
- const topPercent = Math.min(rawTopPercent, 100 - heightPercent);
42774
- return {
42775
- item: entry.segment.draft.item,
42776
- dateKey: entry.segment.dateKey,
42777
- startsAtMinutes: entry.segment.startsAtMinutes,
42778
- endsAtMinutes: entry.segment.endsAtMinutes,
42779
- visibleStartsAtMinutes: entry.visibleStart,
42780
- visibleEndsAtMinutes: entry.visibleEnd,
42781
- topPercent,
42782
- heightPercent,
42783
- isClippedStart: entry.segment.continuesBefore || entry.segment.startsAtMinutes < entry.visibleStart,
42784
- isClippedEnd: entry.segment.continuesAfter || entry.segment.endsAtMinutes > entry.visibleEnd,
42785
- laneIndex: lane.laneIndex,
42786
- laneCount: lane.laneCount,
42787
- color: entry.segment.draft.color
42788
- };
42789
- }
42790
- function resolveBounds(bounds) {
42791
- return {
42792
- minStartHour: bounds?.minStartHour ?? DEFAULT_BOUNDS.minStartHour,
42793
- maxEndHour: bounds?.maxEndHour ?? DEFAULT_BOUNDS.maxEndHour,
42794
- defaultStartHour: bounds?.defaultStartHour ?? DEFAULT_BOUNDS.defaultStartHour,
42795
- defaultEndHour: bounds?.defaultEndHour ?? DEFAULT_BOUNDS.defaultEndHour,
42796
- minHeightPercent: bounds?.minHeightPercent ?? DEFAULT_BOUNDS.minHeightPercent
42797
- };
42798
- }
42799
- function resolveHourRange(segments, bounds) {
42800
- if (segments.length === 0) return {
42801
- startHour: bounds.defaultStartHour,
42802
- endHour: bounds.defaultEndHour
42803
- };
42804
- const earliestStart = Math.min(...segments.map((segment) => segment.startsAtMinutes));
42805
- const latestEnd = Math.max(...segments.map((segment) => segment.endsAtMinutes));
42806
- const startHour = Math.max(bounds.minStartHour, Math.floor(earliestStart / 60));
42807
- const endHour = Math.min(bounds.maxEndHour, Math.ceil(latestEnd / 60));
42808
- return {
42809
- startHour,
42810
- endHour: Math.max(startHour + 1, endHour)
42811
- };
42812
- }
42813
- function buildHourTicks(startHour, endHour) {
42814
- const ticks = [];
42815
- for (let hour = startHour; hour <= endHour; hour += 1) ticks.push(hour);
42816
- return ticks;
42817
- }
42818
- function groupSegmentsByDate(segments) {
42819
- const grouped = /* @__PURE__ */ new Map();
42820
- for (const segment of segments) {
42821
- const existing = grouped.get(segment.dateKey) ?? [];
42822
- existing.push(segment);
42823
- grouped.set(segment.dateKey, existing);
42824
- }
42825
- return grouped;
42826
- }
42827
- function toRangePercent(minutes, rangeMinutes) {
42828
- if (rangeMinutes <= 0) return 0;
42829
- return minutes / rangeMinutes * 100;
42830
- }
42831
- function getTimetableDays(viewDate, view, startOfWeek) {
42832
- if (view === "day") return [viewDate];
42833
- return getWeekCalendarDays(viewDate, startOfWeek);
42834
- }
42835
- function enumerateDateKeys(startDateKey, endDateKey) {
42836
- if (startDateKey > endDateKey) throw new Error(`Invalid timetable date range: ${startDateKey} is after ${endDateKey}`);
42837
- const start = parseDateKey(startDateKey);
42838
- parseDateKey(endDateKey);
42839
- const keys = [];
42840
- let cursor = start;
42841
- while (formatDateKey(cursor) <= endDateKey) {
42842
- keys.push(formatDateKey(cursor));
42843
- cursor = addDays(cursor, 1);
42844
- }
42845
- return keys;
42846
- }
42847
- function parseDateKey(dateKey) {
42848
- const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
42849
- if (match === null) throw new Error(`Invalid timetable date key: ${dateKey}`);
42850
- const [, year, month, day] = match;
42851
- return {
42852
- year: Number(year),
42853
- month: Number(month),
42854
- day: Number(day)
42855
- };
42856
- }
42857
- //#endregion
42858
- //#region ../calendar/src/group.ts
42859
- /**
42860
- * Group items by their local date (YYYY-MM-DD in the item's own timezone).
42861
- *
42862
- * Generic over `T`. Items with un-parseable startsAt or invalid timezone are
42863
- * silently dropped — call sites that need diagnostics should validate upstream.
42864
- *
42865
- * The output map preserves insertion order of dates (ES2015 spec) and the
42866
- * relative order of items within each date matches input order.
42867
- */
42868
- function groupItemsByDate(items, accessors) {
42869
- const grouped = /* @__PURE__ */ new Map();
42870
- for (const item of items) {
42871
- const dateKey = getLocalDateKey(accessors.getStartsAt(item), accessors.getTimeZone(item));
42872
- if (dateKey == null) continue;
42873
- const existing = grouped.get(dateKey);
42874
- if (existing !== void 0) existing.push(item);
42875
- else grouped.set(dateKey, [item]);
42876
- }
42877
- return grouped;
42878
- }
42879
- //#endregion
42880
- //#region ../blocks/src/system/runtime/nodes/events/shared/eventCapacity.ts
42881
- /**
42882
- * Compute remaining spots for an event from `availableSpots` with a fallback
42883
- * to `capacity - registeredCount`. Returns `{ available: null, isFull: false }`
42884
- * for uncapped events so call sites can render no capacity indicator at all.
42885
- *
42886
- * The single source of truth for capacity math across event cards, agenda
42887
- * rows, and timetable cells — keep wording variations in the call sites.
42888
- */
42889
- function getEventAvailability(event) {
42890
- if (event.capacity == null) return {
42891
- available: null,
42892
- isFull: false
42893
- };
42894
- const available = event.availableSpots ?? event.capacity - event.registeredCount;
42895
- if (available <= 0) return {
42896
- available: 0,
42897
- isFull: true
42898
- };
42899
- return {
42900
- available,
42901
- isFull: false
42902
- };
42903
- }
42904
- /**
42905
- * Derive the three-state availability status (available / filling / waitlist)
42906
- * plus a short pill label. Builds on {@link getEventAvailability} so the
42907
- * capacity math (availableSpots with a `capacity - registeredCount` fallback)
42908
- * stays consistent with the binary helper used elsewhere.
42909
- */
42910
- function getEventAvailabilityStatus(event, options) {
42911
- const threshold = options?.fillingThreshold ?? 2;
42912
- const { available, isFull } = getEventAvailability(event);
42913
- if (available == null) return {
42914
- status: "available",
42915
- available: null,
42916
- isFull: false,
42917
- label: null
42918
- };
42919
- if (isFull) return {
42920
- status: "waitlist",
42921
- available: 0,
42922
- isFull: true,
42923
- label: event.waitlistEnabled ? "Waitlist" : "Full"
42924
- };
42925
- if (available <= threshold) return {
42926
- status: "filling",
42927
- available,
42928
- isFull: false,
42929
- label: `${available} left`
42930
- };
42931
- return {
42932
- status: "available",
42933
- available,
42934
- isFull: false,
42935
- label: `${available} places`
42936
- };
42937
- }
42938
43622
  //#endregion
42939
- //#region ../blocks/src/system/runtime/nodes/events/shared/utils.ts
43623
+ //#region ../blocks/src/system/runtime/nodes/events/shared/listingLayout.ts
42940
43624
  /**
42941
- * Shared utility functions for event blocks
43625
+ * Layout classes for event listing containers and cards. Presentation
43626
+ * plumbing rather than event domain logic, so it lives beside the components.
42942
43627
  */
42943
43628
  /**
42944
43629
  * Column class mappings for grid layouts, derived from the canonical
@@ -42973,180 +43658,6 @@ function getCardOrientation(layout) {
42973
43658
  case "compact": return "vertical";
42974
43659
  }
42975
43660
  }
42976
- /**
42977
- * Format date for display.
42978
- * Returns: "Monday, January 15, 2025"
42979
- */
42980
- function formatDate$2(isoTimestamp, options) {
42981
- return formatBookingDate(toScheduledBookingDateTime({
42982
- startsAt: isoTimestamp,
42983
- timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
42984
- locale: options?.locale ?? "en-US"
42985
- }), {
42986
- weekday: "long",
42987
- year: "numeric",
42988
- month: "long",
42989
- day: "numeric"
42990
- });
42991
- }
42992
- /**
42993
- * Format time for display.
42994
- * Returns: "10:00 AM"
42995
- */
42996
- function formatTime(isoTimestamp, options) {
42997
- return formatBookingTime(toScheduledBookingDateTime({
42998
- startsAt: isoTimestamp,
42999
- timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
43000
- locale: options?.locale ?? "en-US"
43001
- }), {
43002
- hour: "numeric",
43003
- minute: "2-digit",
43004
- hour12: true
43005
- });
43006
- }
43007
- function formatEventDateParts(args) {
43008
- const scheduledRange = toScheduledBookingDateRange({
43009
- startsAt: args.startsAt,
43010
- endsAt: args.endsAt,
43011
- timeZone: toBookingTimeZone(args.timeZone ?? "UTC"),
43012
- locale: args.locale ?? "en-US"
43013
- });
43014
- const { date, time: start } = formatBookingDateParts(scheduledRange, { dateFormat: {
43015
- weekday: "long",
43016
- year: "numeric",
43017
- month: "long",
43018
- day: "numeric"
43019
- } });
43020
- return {
43021
- date,
43022
- timeRange: `${start} – ${formatBookingTime(toScheduledBookingDateTime({
43023
- startsAt: args.endsAt,
43024
- timeZone: scheduledRange.timeZone,
43025
- locale: args.locale ?? "en-US"
43026
- }))}`
43027
- };
43028
- }
43029
- function formatEventCompactDateRail(isoTimestamp, options) {
43030
- const dateTime = toScheduledBookingDateTime({
43031
- startsAt: isoTimestamp,
43032
- timeZone: toBookingTimeZone(options?.timeZone ?? "UTC"),
43033
- locale: options?.locale ?? "en-US"
43034
- });
43035
- const weekday = formatBookingDate(dateTime, { weekday: "short" }).toLocaleUpperCase(options?.locale ?? "en-US");
43036
- const month = formatBookingDate(dateTime, { month: "short" }).toLocaleUpperCase(options?.locale ?? "en-US");
43037
- return {
43038
- weekday,
43039
- day: formatBookingDate(dateTime, { day: "numeric" }),
43040
- month,
43041
- label: formatBookingDate(dateTime, {
43042
- weekday: "long",
43043
- year: "numeric",
43044
- month: "long",
43045
- day: "numeric"
43046
- })
43047
- };
43048
- }
43049
- function formatTeamMemberNames(names) {
43050
- const normalized = names.map((name) => name.trim()).filter(Boolean);
43051
- if (normalized.length === 0) return null;
43052
- const [firstName, secondName] = normalized;
43053
- if (!firstName) return null;
43054
- if (normalized.length === 1) return firstName;
43055
- if (normalized.length === 2 && secondName) return `${firstName} and ${secondName}`;
43056
- const finalName = normalized[normalized.length - 1] ?? "";
43057
- return `${normalized.slice(0, -1).join(", ")}, and ${finalName}`;
43058
- }
43059
- function formatEventTeacherLine(event) {
43060
- const names = formatTeamMemberNames((event.teamMembers ?? []).map((teamMember) => teamMember.name));
43061
- return names ? `with ${names}` : null;
43062
- }
43063
- /**
43064
- * Group events by their local date (YYYY-MM-DD key in each event's own
43065
- * timezone). Thin domain wrapper over `groupItemsByDate` from the calendar
43066
- * package; preserves the historical `Map<string, PublicEvent[]>` shape.
43067
- */
43068
- function groupEventsByDate(events, calendarTimeZone) {
43069
- const grouped = groupItemsByDate(events, {
43070
- getStartsAt: (event) => event.startsAt,
43071
- getTimeZone: () => calendarTimeZone
43072
- });
43073
- const map = /* @__PURE__ */ new Map();
43074
- for (const [dateKey, bucket] of grouped) map.set(dateKey, [...bucket]);
43075
- return map;
43076
- }
43077
- /**
43078
- * Format event price for display.
43079
- * @returns "Free", "£25", "£12 - £25", "Free / £12 - £25", "Course (From £40)", etc.
43080
- *
43081
- * Events render compact amounts (no trailing ".00"); the actual ADT→string
43082
- * mapping lives in `formatBookingPriceDisplay` in @riverbankcms/core so all
43083
- * surfaces stay consistent.
43084
- */
43085
- function formatEventPrice(event) {
43086
- return formatEventPricing(resolveRuntimeEventPricing(event), { decimals: "auto" });
43087
- }
43088
- function closedEventBookingLabel(reason) {
43089
- switch (reason) {
43090
- case "occurrence_cancelled":
43091
- case "series_cancelled": return "Cancelled";
43092
- case "occurrence_completed":
43093
- case "series_completed": return "Completed";
43094
- case "series_draft":
43095
- case "series_archived":
43096
- case "parent_archived":
43097
- case "parent_inconsistent":
43098
- case "not_public":
43099
- case "already_started":
43100
- case "enrollment_closed": return "Registration closed";
43101
- default: return assertNever$24(reason);
43102
- }
43103
- }
43104
- function resolveEventHref(event) {
43105
- const destination = resolveRuntimeEventDestination(event);
43106
- return destination.kind === "external" ? destination.url : destination.path;
43107
- }
43108
- function resolveEventLinkProps(event) {
43109
- const href = resolveEventHref(event);
43110
- return resolveRuntimeEventDestination(event).kind === "external" ? {
43111
- href,
43112
- target: "_blank",
43113
- rel: "noopener noreferrer"
43114
- } : { href };
43115
- }
43116
- /**
43117
- * Resolve the CTA state for an event.
43118
- * Shared by EventCard and EventCompactRow to keep sold-out, registration-closed,
43119
- * and course-specific defaults consistent.
43120
- */
43121
- function resolveEventCta(event, buttonText) {
43122
- const { isFull: isSoldOut } = getEventAvailability(event);
43123
- const destination = resolveRuntimeEventDestination(event);
43124
- const isExternal = destination.kind === "external";
43125
- const defaultButtonText = isExternal ? destination.label : event.bookingScope === "whole_series" ? "Book course" : "Book";
43126
- const effectiveButtonText = buttonText ?? defaultButtonText;
43127
- const href = resolveEventHref(event);
43128
- if (event.bookingAvailability.kind === "closed") {
43129
- const linksToClosedWholeSeriesDetails = event.bookingAvailability.reason === "enrollment_closed" && !isExternal;
43130
- return {
43131
- label: closedEventBookingLabel(event.bookingAvailability.reason),
43132
- href,
43133
- external: isExternal,
43134
- hidden: !linksToClosedWholeSeriesDetails
43135
- };
43136
- }
43137
- if (!isExternal && isSoldOut) return {
43138
- label: "Sold out",
43139
- href,
43140
- external: false,
43141
- hidden: true
43142
- };
43143
- return {
43144
- label: effectiveButtonText,
43145
- href,
43146
- external: isExternal,
43147
- hidden: false
43148
- };
43149
- }
43150
43661
  //#endregion
43151
43662
  //#region ../blocks/src/system/media/slotContracts.ts
43152
43663
  const DEFAULT_MIN_SOURCE_WIDTH = 1200;
@@ -44417,7 +44928,6 @@ const EventCard = ({ event, cardRole = "default", buttonVariant = "primary", but
44417
44928
  const formattedDate = formatDate$2(event.startsAt, { timeZone });
44418
44929
  const formattedTime = `${formatTime(event.startsAt, { timeZone })} - ${formatTime(event.endsAt, { timeZone })}`;
44419
44930
  const cta = resolveEventCta(event, buttonText);
44420
- const { available: spotsLeft, isFull: isSoldOut } = getEventAvailability(event);
44421
44931
  const cardClass = boxStyleClassNames({ role: cardRole }, { baseClass: "event-card" });
44422
44932
  const buttonClass = themeButtonClassName({
44423
44933
  variant: buttonVariant,
@@ -44433,10 +44943,7 @@ const EventCard = ({ event, cardRole = "default", buttonVariant = "primary", but
44433
44943
  } : null;
44434
44944
  const hasImage = Boolean(showImage && image);
44435
44945
  const teacherLine = formatEventTeacherLine(event);
44436
- const capacityText = showCapacity ? formatCapacityText(event, {
44437
- isSoldOut,
44438
- spotsLeft
44439
- }) : null;
44946
+ const capacityText = showCapacity ? formatEventRemainingPlaces(event, { full: "Sold out" }) : null;
44440
44947
  return /* @__PURE__ */ jsxs("article", {
44441
44948
  className: `${cardClass} group rb-flex rb-flex-col rb-h-full ${eventCardOrientationClass(orientation)}`,
44442
44949
  "data-orientation": orientation,
@@ -44502,7 +45009,11 @@ function EventCardContent(props) {
44502
45009
  }),
44503
45010
  /* @__PURE__ */ jsx("h3", {
44504
45011
  className: "card-title rb-mb-3",
44505
- children: props.title
45012
+ children: /* @__PURE__ */ jsx("a", {
45013
+ className: "event-title-link",
45014
+ ...resolveEventLinkProps(event),
45015
+ children: props.title
45016
+ })
44506
45017
  }),
44507
45018
  /* @__PURE__ */ jsx("div", {
44508
45019
  className: "rb-mb-2 rb-caption",
@@ -44579,13 +45090,6 @@ function eventCardOrientationClass(orientation) {
44579
45090
  case "vertical": return "event-card--vertical";
44580
45091
  }
44581
45092
  }
44582
- function formatCapacityText(event, options) {
44583
- if (event.capacity == null) return null;
44584
- if (options.isSoldOut) return "Sold out";
44585
- if (options.spotsLeft == null) return null;
44586
- const count = options.spotsLeft;
44587
- return `${count} ${count === 1 ? "spot" : "spots"} available`;
44588
- }
44589
45093
  function getCategoryBadgeStyle(background) {
44590
45094
  const normalized = background?.trim() ?? "";
44591
45095
  if (!normalized) return {
@@ -44654,7 +45158,11 @@ const EventCompactRow = ({ event, buttonVariant = "primary", buttonText }) => {
44654
45158
  className: "event-compact-row-content",
44655
45159
  children: [/* @__PURE__ */ jsx("h3", {
44656
45160
  className: "event-compact-row-title",
44657
- children: event.title
45161
+ children: /* @__PURE__ */ jsx("a", {
45162
+ className: "event-title-link",
45163
+ ...resolveEventLinkProps(event),
45164
+ children: event.title
45165
+ })
44658
45166
  }), /* @__PURE__ */ jsxs("div", {
44659
45167
  className: "event-compact-row-meta",
44660
45168
  children: [
@@ -44743,6 +45251,23 @@ function DateTimeTile({ startsAt, endsAt, timeZone }) {
44743
45251
  });
44744
45252
  }
44745
45253
  //#endregion
45254
+ //#region ../blocks/src/system/runtime/nodes/events/shared/OfferingTitle.tsx
45255
+ /**
45256
+ * Heading for an offering card or row. Links to the offering's page when one
45257
+ * exists so a sold-out or closed offering stays reachable (issue 1372); the
45258
+ * `.event-title-link` rule lives in theme-core's event-list.css.
45259
+ */
45260
+ function OfferingTitle({ className, path, title }) {
45261
+ return /* @__PURE__ */ jsx("h3", {
45262
+ className,
45263
+ children: path ? /* @__PURE__ */ jsx("a", {
45264
+ className: "event-title-link",
45265
+ href: path,
45266
+ children: title
45267
+ }) : title
45268
+ });
45269
+ }
45270
+ //#endregion
44746
45271
  //#region ../blocks/src/system/runtime/nodes/events/shared/listOptions.ts
44747
45272
  const eventPageSizeValues = [
44748
45273
  3,
@@ -46087,7 +46612,7 @@ const eventCalendarAdditionalFields = [
46087
46612
  {
46088
46613
  id: "showCapacity",
46089
46614
  type: "boolean",
46090
- label: "Show available spots",
46615
+ label: "Show available places",
46091
46616
  description: "Display remaining capacity on event cards and timetable cells",
46092
46617
  required: false,
46093
46618
  defaultValue: true,
@@ -55764,7 +56289,7 @@ function EventFacts({ selection, selected, venue, teamMembers, pricing, showCapa
55764
56289
  children: [/* @__PURE__ */ jsx("div", {
55765
56290
  className: "rb-label rb-mb-2",
55766
56291
  children: "Places"
55767
- }), /* @__PURE__ */ jsxs("p", { children: [selected.capacity, " places"] })]
56292
+ }), /* @__PURE__ */ jsx("p", { children: formatTotalCapacity(selected.capacity) })]
55768
56293
  }) : null,
55769
56294
  pricing,
55770
56295
  selected.bookingScope === "whole_series" ? /* @__PURE__ */ jsxs("div", {
@@ -56608,9 +57133,10 @@ function CourseCompactRow({ course, path }) {
56608
57133
  }),
56609
57134
  /* @__PURE__ */ jsxs("div", {
56610
57135
  className: "event-compact-row-content",
56611
- children: [/* @__PURE__ */ jsx("h3", {
57136
+ children: [/* @__PURE__ */ jsx(OfferingTitle, {
56612
57137
  className: "event-compact-row-title",
56613
- children: course.title
57138
+ path,
57139
+ title: course.title
56614
57140
  }), /* @__PURE__ */ jsxs("div", {
56615
57141
  className: "event-compact-row-meta",
56616
57142
  children: [
@@ -56667,9 +57193,10 @@ function CourseFeatureCard({ course, path, buttonVariant, buttonText }) {
56667
57193
  children: /* @__PURE__ */ jsxs("div", {
56668
57194
  className: "card-content rb-flex rb-flex-1 rb-flex-col",
56669
57195
  children: [
56670
- /* @__PURE__ */ jsx("h3", {
57196
+ /* @__PURE__ */ jsx(OfferingTitle, {
56671
57197
  className: "card-title rb-mb-3",
56672
- children: course.title
57198
+ path,
57199
+ title: course.title
56673
57200
  }),
56674
57201
  /* @__PURE__ */ jsxs("div", {
56675
57202
  className: "rb-mb-2 rb-caption",
@@ -57525,8 +58052,8 @@ const AgendaEventCard = ({ event, showCapacity, onClick }) => {
57525
58052
  const eventLinkProps = resolveEventLinkProps(event);
57526
58053
  const categoryColor = event.eventCategory?.color || "rgb(var(--tb-accent))";
57527
58054
  const price = formatEventPrice(event);
57528
- const { available, isFull } = getEventAvailability(event);
57529
- const capacity = showCapacity && available != null ? isFull ? "Full" : `${available} spots left` : null;
58055
+ const capacity = showCapacity ? formatEventRemainingPlaces(event) : null;
58056
+ const isFull = isEventFull(event);
57530
58057
  const title = event.title;
57531
58058
  const timeZone = event.timeZone ?? null;
57532
58059
  const teacherLine = formatEventTeacherLine(event);
@@ -57968,7 +58495,7 @@ function buildTimetableModel(input) {
57968
58495
  getEndsAt: (event) => event.endsAt,
57969
58496
  getTimeZone: (event) => event.timeZone ?? "UTC",
57970
58497
  getColor: (event) => event.eventCategory?.color ?? null,
57971
- getOrderKey: (event) => `${event.title}${event.id}`
58498
+ getOrderKey: (event) => `${event.title}\u0000${event.id}`
57972
58499
  });
57973
58500
  return {
57974
58501
  hourTicks: [...generic.hourTicks],
@@ -58005,18 +58532,9 @@ function toEventCell(cell) {
58005
58532
  };
58006
58533
  }
58007
58534
  function formatTimetableCapacity(event) {
58008
- const { available, isFull } = getEventAvailability(event);
58009
- if (available == null) return {
58010
- label: null,
58011
- isFull: false
58012
- };
58013
- if (isFull) return {
58014
- label: "Full",
58015
- isFull: true
58016
- };
58017
58535
  return {
58018
- label: `${available} spots left`,
58019
- isFull: false
58536
+ label: formatEventRemainingPlaces(event, { compact: true }),
58537
+ isFull: isEventFull(event)
58020
58538
  };
58021
58539
  }
58022
58540
  function formatEventTimeRange(event) {
@@ -58492,7 +59010,7 @@ function EventRegistrationSSR({ blockId, subject, children, ...sourceProps }) {
58492
59010
  className: `event-registration ${className ?? ""}`.trim(),
58493
59011
  "data-block": "event-registration",
58494
59012
  "data-rb-island-required": "true",
58495
- "data-rb-island-error-message": "Booking options failed to load. Please refresh the page.",
59013
+ "data-rb-island-error-message": "We couldn’t load the booking options for this event. Please refresh this page and try again. If this keeps happening, contact the organiser for help.",
58496
59014
  "data-rb-island-error-class": "event-registration__hydration-error",
58497
59015
  ...getIslandRootProps("event-registration", blockId),
58498
59016
  children: [
@@ -64363,6 +64881,32 @@ function isSiteStyleTransitionStyleGroup(value) {
64363
64881
  function boundaryMapForAdjacentBlockIds(boundaries) {
64364
64882
  return new Map(boundaries.map((boundary) => [boundaryKey(boundary.previousBlockId, boundary.nextBlockId), boundary]));
64365
64883
  }
64884
+ /** Keep a surviving block's outgoing treatment when omitted blocks change its neighbor. */
64885
+ function boundariesForSurvivingBlocks(boundaries, blocks) {
64886
+ const outgoing = new Map(boundaries.map((boundary) => [boundary.previousBlockId, boundary]));
64887
+ const incoming = new Map(boundaries.map((boundary) => [boundary.nextBlockId, boundary]));
64888
+ return blocks.flatMap((block, index) => {
64889
+ const next = blocks[index + 1];
64890
+ if (!block.id || !next?.id) return [];
64891
+ const boundary = outgoing.get(block.id);
64892
+ if (!boundary) return [];
64893
+ if (boundary.nextBlockId === next.id) return [boundary];
64894
+ const destination = incoming.get(next.id);
64895
+ return [retargetCompiledBoundaryRuntimeForContentAdjacency({
64896
+ ...boundary,
64897
+ boundaryId: `boundary:${block.id}:${next.id}`,
64898
+ nextBlockId: next.id,
64899
+ to: destination?.to ?? boundary.to,
64900
+ cssVars: {
64901
+ ...boundary.cssVars,
64902
+ to: destination?.cssVars.to ?? boundary.cssVars.to
64903
+ }
64904
+ }, {
64905
+ currentContent: block.content,
64906
+ nextContent: next.content
64907
+ })];
64908
+ });
64909
+ }
64366
64910
  function boundaryBetweenBlocks(previousBlock, nextBlock, boundaries) {
64367
64911
  const previousBlockId = previousBlock?.id ?? null;
64368
64912
  const nextBlockId = nextBlock?.id ?? null;
@@ -64454,41 +64998,179 @@ function toPresentationPage(page) {
64454
64998
  };
64455
64999
  }
64456
65000
  //#endregion
65001
+ //#region ../blocks/src/pageRendererBlockSupport.tsx
65002
+ /**
65003
+ * Support for rendering one block section: what a block that cannot render
65004
+ * leaves behind, how an override is found, and the props both override paths
65005
+ * hand to a component. `PageRenderer` owns the page-level composition; this
65006
+ * module owns the pieces it reaches for per block.
65007
+ */
65008
+ function fallback(message) {
65009
+ return /* @__PURE__ */ jsx("div", {
65010
+ className: "rb-p-6 rb-caption",
65011
+ style: textColorStyle("mutedText"),
65012
+ children: message
65013
+ });
65014
+ }
65015
+ /**
65016
+ * A page with nothing to show. Authoring surfaces get the diagnostic; a public
65017
+ * page renders nothing at all rather than telling a visitor about the outline.
65018
+ */
65019
+ function emptyPageNotice(audience) {
65020
+ if (audience === "public") return null;
65021
+ return /* @__PURE__ */ jsx("div", {
65022
+ className: "rb-p-6 rb-caption",
65023
+ style: textColorStyle("mutedText"),
65024
+ children: "No blocks found."
65025
+ });
65026
+ }
65027
+ /**
65028
+ * A public page dropped a block, so nothing in the rendered HTML says the
65029
+ * content is missing. Say it in the logs instead.
65030
+ */
65031
+ function warnOmittedFromPublicPage(input) {
65032
+ try {
65033
+ console.warn("[blocks:PageRenderer] omitted a non-renderable block from a public page", {
65034
+ blockId: input.block.id ?? null,
65035
+ blockKind: input.block.kind,
65036
+ outcome: input.outcome,
65037
+ ...input.admissionError ? { error: input.admissionError } : {}
65038
+ });
65039
+ } catch {}
65040
+ }
65041
+ function shouldDebugCustomBlocks() {
65042
+ try {
65043
+ return globalThis.process?.env?.DEBUG_CUSTOM_BLOCKS === "1";
65044
+ } catch {
65045
+ return false;
65046
+ }
65047
+ }
65048
+ /**
65049
+ * Look up a block override component by block kind.
65050
+ * Supports both full ("block.hero") and short ("hero") keys.
65051
+ */
65052
+ function getBlockOverride(blockKind, overrides) {
65053
+ if (!overrides) return void 0;
65054
+ if (overrides[blockKind]) return overrides[blockKind];
65055
+ const shortKind = blockKind.replace(/^block\./, "");
65056
+ if (overrides[shortKind]) return overrides[shortKind];
65057
+ }
65058
+ /**
65059
+ * Resolves block content based on stage priority.
65060
+ * For draft/preview: draftContent takes priority over content.
65061
+ * For published: content is the source of truth.
65062
+ * Returns empty object if no content exists (validator will handle placeholders if needed).
65063
+ */
65064
+ function getRenderableContent(block, previewStage = "published") {
65065
+ const source = previewStage === "preview" && block.draftContent ? block.draftContent : block.content ?? {};
65066
+ if (!isObjectRecord$1(source)) return {};
65067
+ return source;
65068
+ }
65069
+ function resolvePageRendererImageResolveContext(dataContext) {
65070
+ const input = dataContext?.imageResolveContext;
65071
+ return buildResolveImageUrlContext({
65072
+ siteId: input?.siteId ?? dataContext?.siteId,
65073
+ supabaseUrl: input?.supabaseUrl ?? dataContext?.supabaseUrl,
65074
+ mediaAssetIndex: input?.mediaAssetIndex,
65075
+ mediaAssets: input?.mediaAssets ?? dataContext?.mediaAssets
65076
+ });
65077
+ }
65078
+ /**
65079
+ * The RSC-safe props every override component receives, whether the block
65080
+ * resolved a manifest or is being rendered from an override alone.
65081
+ */
65082
+ function buildBlockOverrideProps(input) {
65083
+ return {
65084
+ content: input.content,
65085
+ ...overrideThemeProps(input.themeResources),
65086
+ ...blockOverrideContextProps(input.dataContext),
65087
+ data: input.data,
65088
+ imageResolveContext: input.imageResolveContext,
65089
+ blockId: input.block.id ?? null,
65090
+ blockKind: input.block.kind
65091
+ };
65092
+ }
65093
+ /** The render-context half of the override props, absent context included. */
65094
+ function blockOverrideContextProps(dataContext) {
65095
+ const context = dataContext ?? {};
65096
+ return {
65097
+ siteId: context.siteId,
65098
+ apiBaseUrl: context.apiBaseUrl,
65099
+ bookingTimeZone: context.bookingTimeZone,
65100
+ portalToken: context.portalToken ?? null,
65101
+ previewToken: context.previewToken ?? null,
65102
+ stage: context.previewStage,
65103
+ occurrenceContext: context.occurrenceContext ?? null,
65104
+ entrySubrouteContext: context.entrySubrouteContext ?? null,
65105
+ contentEntry: context.contentEntry ?? null,
65106
+ pageSurface: context.pageSurface ?? null,
65107
+ subject: context.subject ?? null,
65108
+ supabaseUrl: context.supabaseUrl
65109
+ };
65110
+ }
65111
+ const blockComponentCache = /* @__PURE__ */ new WeakMap();
65112
+ const DEFAULT_PERF_SLOW_BLOCK_RENDER_THRESHOLD_MS = 25;
65113
+ let cachedPerfSlowBlockRenderThresholdMs = null;
65114
+ function getPerfSlowBlockRenderThresholdMs() {
65115
+ if (cachedPerfSlowBlockRenderThresholdMs !== null) return cachedPerfSlowBlockRenderThresholdMs;
65116
+ const raw = globalThis.process?.env?.DEBUG_PERF_BLOCK_MS;
65117
+ const parsed = raw ? Number.parseInt(raw, 10) : NaN;
65118
+ cachedPerfSlowBlockRenderThresholdMs = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PERF_SLOW_BLOCK_RENDER_THRESHOLD_MS;
65119
+ return cachedPerfSlowBlockRenderThresholdMs;
65120
+ }
65121
+ function getCachedBlockComponent(manifest) {
65122
+ const cached = blockComponentCache.get(manifest);
65123
+ if (cached) return cached;
65124
+ const created = makeDefaultBlockComponent({ manifest });
65125
+ blockComponentCache.set(manifest, created);
65126
+ return created;
65127
+ }
65128
+ //#endregion
64457
65129
  //#region ../blocks/src/PageRenderer.tsx
64458
65130
  function PageRenderer$1(props) {
64459
65131
  const page = React$1.useMemo(() => toPresentationPage(props.page), [props.page]);
64460
65132
  return /* @__PURE__ */ jsx(PageRendererCore, {
64461
65133
  ...props,
64462
- page
65134
+ page,
65135
+ audience: "public"
64463
65136
  });
64464
65137
  }
64465
65138
  function PageRendererCore(props) {
64466
- const { page, runtimeBoundaries, dataContext, wrapBlock, wrapBoundary, registry } = props;
65139
+ const { page, audience, runtimeBoundaries, dataContext, wrapBlock, wrapBoundary, registry } = props;
64467
65140
  const imageResolveContext = React$1.useMemo(() => resolvePageRendererImageResolveContext(dataContext), [
64468
65141
  dataContext?.imageResolveContext,
64469
65142
  dataContext?.mediaAssets,
64470
65143
  dataContext?.siteId,
64471
65144
  dataContext?.supabaseUrl
64472
65145
  ]);
64473
- const boundaryByAdjacentBlockIds = React$1.useMemo(() => boundaryMapForAdjacentBlockIds(runtimeBoundaries ?? []), [runtimeBoundaries]);
64474
65146
  const usesRuntimeBoundaries = runtimeBoundaries !== void 0;
64475
65147
  const themeResources = React$1.useMemo(() => resolvePageThemeResources(props), [
64476
65148
  props.rendering,
64477
65149
  props.theme,
64478
65150
  props.themeTokens
64479
65151
  ]);
64480
- if (!page || page.blocks.length === 0) return /* @__PURE__ */ jsx("div", {
64481
- className: "rb-p-6 rb-caption",
64482
- style: textColorStyle("mutedText"),
64483
- children: "No blocks found."
64484
- });
64485
65152
  const resolvedRegistry = registry ?? getDefaultComponentRegistry();
64486
65153
  setContextResolveImageUrlContext(imageResolveContext);
64487
- return /* @__PURE__ */ jsx("div", { children: page.blocks.map((block, index) => {
64488
- const blockPosition = pageBlockPosition(index, page.blocks.length);
64489
- const blockKey = block.id ?? `${block.kind}-${index}`;
64490
- const previousBlock = page.blocks[index - 1] ?? null;
64491
- const nextBlock = page.blocks[index + 1] ?? null;
65154
+ const sections = usePageSections(props, {
65155
+ page,
65156
+ themeResources,
65157
+ imageResolveContext,
65158
+ registry: resolvedRegistry
65159
+ });
65160
+ const boundaryByAdjacentBlockIds = React$1.useMemo(() => boundaryMapForAdjacentBlockIds(boundariesForSurvivingBlocks(runtimeBoundaries ?? [], sections.map(({ block }) => ({
65161
+ ...block,
65162
+ content: getRenderableContent(block, dataContext?.previewStage)
65163
+ })))), [
65164
+ runtimeBoundaries,
65165
+ sections,
65166
+ dataContext?.previewStage
65167
+ ]);
65168
+ if (sections.length === 0) return emptyPageNotice(audience);
65169
+ return /* @__PURE__ */ jsx("div", { children: sections.map((section, index) => {
65170
+ const { block, blockKey, node } = section;
65171
+ const blockPosition = pageBlockPosition(index, sections.length);
65172
+ const previousBlock = sections[index - 1]?.block ?? null;
65173
+ const nextBlock = sections[index + 1]?.block ?? null;
64492
65174
  const boundaryBefore = boundaryBetweenBlocks(previousBlock, block, boundaryByAdjacentBlockIds);
64493
65175
  const boundaryAfter = boundaryBetweenBlocks(block, nextBlock, boundaryByAdjacentBlockIds);
64494
65176
  const boundaryElement = renderBoundaryAfterBlock({
@@ -64503,19 +65185,7 @@ function PageRendererCore(props) {
64503
65185
  const managesOwnWrapper = definition?.manifest.behaviours?.managesOwnWrapper;
64504
65186
  const renderableContent = getRenderableContent(block, dataContext?.previewStage);
64505
65187
  const variantKey = definition?.manifest ? resolveVariantKey(definition.manifest, renderableContent) : null;
64506
- const shouldUseWrapper = !managesOwnWrapper || Boolean(wrapBlock);
64507
- const blockSectionProps = buildBlockSectionProps({
64508
- props,
64509
- themeResources,
64510
- block,
64511
- blockKey,
64512
- imageResolveContext,
64513
- registry: resolvedRegistry
64514
- });
64515
- if (!shouldUseWrapper) {
64516
- const rendered = /* @__PURE__ */ jsx(MemoBlockSection, { ...blockSectionProps }, `${blockKey}:block`);
64517
- return boundaryElement ? /* @__PURE__ */ jsxs(React$1.Fragment, { children: [rendered, boundaryElement] }, blockKey) : rendered;
64518
- }
65188
+ if (!(!managesOwnWrapper || Boolean(wrapBlock))) return /* @__PURE__ */ jsxs(React$1.Fragment, { children: [node, boundaryElement] }, blockKey);
64519
65189
  const wrapperClassName = [`block-${cleanKind}`, wrapBlock ? "group relative" : null].filter(Boolean).join(" ");
64520
65190
  const styleGroup = typeof renderableContent.styleGroup === "string" ? renderableContent.styleGroup : void 0;
64521
65191
  const blockLook = typeof renderableContent.blockLook === "string" ? renderableContent.blockLook : void 0;
@@ -64540,7 +65210,7 @@ function PageRendererCore(props) {
64540
65210
  "data-site-style-boundary-after": boundaryAfter?.metrics.size,
64541
65211
  "data-style-group": styleGroup,
64542
65212
  style: blockStyle,
64543
- children: /* @__PURE__ */ jsx(MemoBlockSection, { ...blockSectionProps })
65213
+ children: node
64544
65214
  }, blockKey);
64545
65215
  if (boundaryElement) return /* @__PURE__ */ jsxs(React$1.Fragment, { children: [blockElement, boundaryElement] }, blockKey);
64546
65216
  if (usesRuntimeBoundaries || !isSiteStyleTransitionStyleGroup(styleGroup)) return blockElement;
@@ -64553,18 +65223,69 @@ function PageRendererCore(props) {
64553
65223
  })] }, blockKey);
64554
65224
  }) });
64555
65225
  }
64556
- function resolvePageRendererImageResolveContext(dataContext) {
64557
- const input = dataContext?.imageResolveContext;
64558
- return buildResolveImageUrlContext({
64559
- siteId: input?.siteId ?? dataContext?.siteId,
64560
- supabaseUrl: input?.supabaseUrl ?? dataContext?.supabaseUrl,
64561
- mediaAssetIndex: input?.mediaAssetIndex,
64562
- mediaAssets: input?.mediaAssets ?? dataContext?.mediaAssets
65226
+ /**
65227
+ * Memoised so that a re-render with unchanged inputs reuses the same section
65228
+ * elements and React can skip the block subtrees, exactly as the per-block memo
65229
+ * did before public pages started resolving their bodies up front.
65230
+ */
65231
+ function usePageSections(props, input) {
65232
+ const { page, themeResources, imageResolveContext, registry } = input;
65233
+ return React$1.useMemo(() => admitPageSections({
65234
+ props,
65235
+ blocks: page?.blocks ?? [],
65236
+ audience: props.audience,
65237
+ themeResources,
65238
+ imageResolveContext,
65239
+ registry
65240
+ }), [
65241
+ page,
65242
+ props.audience,
65243
+ themeResources,
65244
+ imageResolveContext,
65245
+ registry,
65246
+ props.dataContext,
65247
+ props.routeMap,
65248
+ props.wrapBlock,
65249
+ props.blockOverrides,
65250
+ props.sdkConfig,
65251
+ props.customBlocks,
65252
+ props.previewAuthoring
65253
+ ]);
65254
+ }
65255
+ /**
65256
+ * Resolves each block's rendered body before the page decides what to wrap.
65257
+ *
65258
+ * On a public page the body is resolved eagerly so a block that cannot render
65259
+ * is dropped along with its wrapper, its data attributes, its boundary reserve
65260
+ * space and its style-group sibling — the section body is the single source of
65261
+ * truth for whether the block is on the page (issue 1378). Authoring surfaces
65262
+ * keep the memoised component so the editor's render-loop guards still hold.
65263
+ */
65264
+ function admitPageSections(input) {
65265
+ const sections = [];
65266
+ input.blocks.forEach((block, index) => {
65267
+ const blockKey = block.id ?? `${block.kind}-${index}`;
65268
+ const blockSectionProps = buildBlockSectionProps({
65269
+ props: input.props,
65270
+ themeResources: input.themeResources,
65271
+ block,
65272
+ blockKey,
65273
+ imageResolveContext: input.imageResolveContext,
65274
+ registry: input.registry
65275
+ });
65276
+ const node = input.audience === "public" ? renderBlockSection(blockSectionProps) : /* @__PURE__ */ jsx(MemoBlockSection, { ...blockSectionProps }, `${blockKey}:block`);
65277
+ if (node === null || node === void 0) return;
65278
+ sections.push({
65279
+ block,
65280
+ blockKey,
65281
+ node
65282
+ });
64563
65283
  });
65284
+ return sections;
64564
65285
  }
64565
- const resolveBookingTimeZone = (dataContext) => dataContext?.bookingTimeZone;
64566
65286
  function buildBlockSectionProps(input) {
64567
65287
  return {
65288
+ audience: input.props.audience,
64568
65289
  themeResources: input.themeResources,
64569
65290
  block: input.block,
64570
65291
  dataContext: input.props.dataContext,
@@ -64580,40 +65301,24 @@ function buildBlockSectionProps(input) {
64580
65301
  };
64581
65302
  }
64582
65303
  /**
64583
- * Look up a block override component by block kind.
64584
- * Supports both full ("block.hero") and short ("hero") keys.
64585
- */
64586
- function getBlockOverride(blockKind, overrides) {
64587
- if (!overrides) return void 0;
64588
- if (overrides[blockKind]) return overrides[blockKind];
64589
- const shortKind = blockKind.replace(/^block\./, "");
64590
- if (overrides[shortKind]) return overrides[shortKind];
64591
- }
64592
- const blockComponentCache = /* @__PURE__ */ new WeakMap();
64593
- const DEFAULT_PERF_SLOW_BLOCK_RENDER_THRESHOLD_MS = 25;
64594
- let cachedPerfSlowBlockRenderThresholdMs = null;
64595
- function getPerfSlowBlockRenderThresholdMs() {
64596
- if (cachedPerfSlowBlockRenderThresholdMs !== null) return cachedPerfSlowBlockRenderThresholdMs;
64597
- const raw = globalThis.process?.env?.DEBUG_PERF_BLOCK_MS;
64598
- const parsed = raw ? Number.parseInt(raw, 10) : NaN;
64599
- cachedPerfSlowBlockRenderThresholdMs = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_PERF_SLOW_BLOCK_RENDER_THRESHOLD_MS;
64600
- return cachedPerfSlowBlockRenderThresholdMs;
64601
- }
64602
- function getCachedBlockComponent(manifest) {
64603
- const cached = blockComponentCache.get(manifest);
64604
- if (cached) return cached;
64605
- const created = makeDefaultBlockComponent({ manifest });
64606
- blockComponentCache.set(manifest, created);
64607
- return created;
64608
- }
64609
- /**
64610
65304
  * Memoized block section component.
64611
65305
  *
64612
65306
  * Custom memoization prevents render loops during editing by explicitly checking
64613
65307
  * referential equality of all props that affect rendering. Standard React.memo
64614
65308
  * wouldn't catch all the render triggers from the editor store.
64615
65309
  */
64616
- const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources, block, dataContext, registry, routeMap, wrapBlock, blockOverrides, sdkConfig, customBlocks, imageResolveContext, previewAuthoring }) {
65310
+ const MemoBlockSection = React$1.memo(function MemoBlockSection(props) {
65311
+ return renderBlockSection(props);
65312
+ }, (prev, next) => {
65313
+ return prev.audience === next.audience && prev.block === next.block && prev.registry === next.registry && prev.dataContext === next.dataContext && prev.themeResources === next.themeResources && prev.routeMap === next.routeMap && prev.block.presentationAdmission === next.block.presentationAdmission && prev.wrapBlock === next.wrapBlock && prev.blockOverrides === next.blockOverrides && prev.sdkConfig === next.sdkConfig && prev.customBlocks === next.customBlocks && prev.imageResolveContext === next.imageResolveContext && prev.previewAuthoring === next.previewAuthoring;
65314
+ });
65315
+ /**
65316
+ * Renders a single block body.
65317
+ *
65318
+ * Returns `null` when the block cannot render and the audience is `public`;
65319
+ * the caller treats that as "this block is not on the page".
65320
+ */
65321
+ function renderBlockSection({ audience, themeResources, block, dataContext, registry, routeMap, wrapBlock, blockOverrides, sdkConfig, customBlocks, imageResolveContext, previewAuthoring }) {
64617
65322
  const perfStart = globalThis.process?.env?.DEBUG_PERF === "1" && typeof window === "undefined" ? Date.now() : 0;
64618
65323
  const fallbackImageResolveContext = getCurrentResolveImageUrlContext();
64619
65324
  const resolvedImageResolveContext = imageResolveContext ?? fallbackImageResolveContext;
@@ -64633,19 +65338,30 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64633
65338
  } catch {}
64634
65339
  return node;
64635
65340
  }
64636
- if (block.presentationAdmission.kind === "invalid") {
64637
- try {
64638
- if (globalThis.process?.env?.NODE_ENV !== "production") console.warn("[blocks:PageRenderer] block admission failed", {
64639
- blockId: block.id ?? null,
64640
- blockKind: block.kind,
64641
- error: block.presentationAdmission.error
64642
- });
64643
- } catch {}
64644
- return finish(fallback(`Invalid content for block kind "${block.kind}".`), {
64645
- outcome: "invalidContent",
64646
- override: false
65341
+ /**
65342
+ * The one place that decides what a block that cannot render leaves behind.
65343
+ * A public page gets nothing (and a log line, since dropping content on a
65344
+ * 200 is otherwise invisible); authoring surfaces get the diagnostic.
65345
+ */
65346
+ function diagnostic(message, detail) {
65347
+ if (audience !== "public") return fallback(message);
65348
+ warnOmittedFromPublicPage({
65349
+ block,
65350
+ ...detail
64647
65351
  });
65352
+ return null;
64648
65353
  }
65354
+ function withBlockWrapper(node) {
65355
+ if (node === null) return null;
65356
+ return wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, node) }) : /* @__PURE__ */ jsx(Fragment, { children: node });
65357
+ }
65358
+ if (block.presentationAdmission.kind === "invalid") return finish(diagnostic(`Invalid content for block kind "${block.kind}".`, {
65359
+ outcome: "invalidContent",
65360
+ admissionError: block.presentationAdmission.error
65361
+ }), {
65362
+ outcome: "invalidContent",
65363
+ override: false
65364
+ });
64649
65365
  const OverrideComponent = getBlockOverride(block.kind, blockOverrides);
64650
65366
  requireBlockOverride(themeResources, OverrideComponent, block.kind);
64651
65367
  let definition = getBlockDefinition(block.kind);
@@ -64658,25 +65374,14 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64658
65374
  }).findManifest(block.kind)?.descriptor.definition;
64659
65375
  if (!definition) {
64660
65376
  if (OverrideComponent) {
64661
- const rendered = /* @__PURE__ */ jsx(OverrideComponent, {
65377
+ const rendered = /* @__PURE__ */ jsx(OverrideComponent, { ...buildBlockOverrideProps({
65378
+ block,
64662
65379
  content: getRenderableContent(block, dataContext?.previewStage),
64663
- ...overrideThemeProps(themeResources),
64664
65380
  data: void 0,
64665
- siteId: dataContext?.siteId,
64666
- apiBaseUrl: dataContext?.apiBaseUrl,
64667
- portalToken: dataContext?.portalToken ?? null,
64668
- previewToken: dataContext?.previewToken ?? null,
64669
- stage: dataContext?.previewStage,
64670
- occurrenceContext: dataContext?.occurrenceContext ?? null,
64671
- entrySubrouteContext: dataContext?.entrySubrouteContext ?? null,
64672
- contentEntry: dataContext?.contentEntry ?? null,
64673
- pageSurface: dataContext?.pageSurface ?? null,
64674
- subject: dataContext?.subject ?? null,
64675
- supabaseUrl: dataContext?.supabaseUrl,
64676
- imageResolveContext: resolvedImageResolveContext,
64677
- blockId: block.id ?? null,
64678
- blockKind: block.kind
64679
- });
65381
+ themeResources,
65382
+ dataContext,
65383
+ imageResolveContext: resolvedImageResolveContext
65384
+ }) });
64680
65385
  return finish(wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, rendered) }) : /* @__PURE__ */ jsx(Fragment, { children: rendered }), {
64681
65386
  outcome: "overrideWithoutDefinition",
64682
65387
  override: true,
@@ -64692,8 +65397,7 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64692
65397
  dbCustomBlocksCount: customBlocks?.length ?? 0
64693
65398
  });
64694
65399
  } catch {}
64695
- const rendered = fallback(block.kind.startsWith("custom.") ? `No renderer available for block kind "${block.kind}". The custom block definition was not found (missing manifest).` : `No renderer available for block kind "${block.kind}".`);
64696
- return finish(wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, rendered) }) : /* @__PURE__ */ jsx(Fragment, { children: rendered }), {
65400
+ return finish(withBlockWrapper(diagnostic(block.kind.startsWith("custom.") ? `No renderer available for block kind "${block.kind}". The custom block definition was not found (missing manifest).` : `No renderer available for block kind "${block.kind}".`, { outcome: "missingDefinition" })), {
64697
65401
  outcome: "missingDefinition",
64698
65402
  override: false
64699
65403
  });
@@ -64708,8 +65412,7 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64708
65412
  manifestKeys: Object.keys(definition.manifest ?? {}).sort()
64709
65413
  });
64710
65414
  } catch {}
64711
- const rendered = fallback(block.kind.startsWith("custom.") ? `No renderer available for block kind "${block.kind}". This custom block has no layout/variants, so it must be rendered via blockOverrides["${block.kind}"].` : `No renderer available for block kind "${block.kind}".`);
64712
- return finish(wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, rendered) }) : /* @__PURE__ */ jsx(Fragment, { children: rendered }), {
65415
+ return finish(withBlockWrapper(diagnostic(block.kind.startsWith("custom.") ? `No renderer available for block kind "${block.kind}". This custom block has no layout/variants, so it must be rendered via blockOverrides["${block.kind}"].` : `No renderer available for block kind "${block.kind}".`, { outcome: "missingLayout" })), {
64713
65416
  outcome: "missingLayout",
64714
65417
  override: false
64715
65418
  });
@@ -64719,39 +65422,24 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64719
65422
  if (block.presentationAdmission.kind === "editor") {
64720
65423
  const draft = validateManifestDraft(compileManifestContentContract(definition.manifest), content);
64721
65424
  if (draft.kind === "previewable") data = draft.data;
64722
- else {
64723
- const rendered = fallback(`Unable to render block kind "${block.kind}".`);
64724
- return finish(wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, rendered) }) : /* @__PURE__ */ jsx(Fragment, { children: rendered }), {
64725
- outcome: "invalidPlaceholder",
64726
- override: false
64727
- });
64728
- }
65425
+ else return finish(withBlockWrapper(diagnostic(`Unable to render block kind "${block.kind}".`, { outcome: "invalidPlaceholder" })), {
65426
+ outcome: "invalidPlaceholder",
65427
+ override: false
65428
+ });
64729
65429
  }
64730
65430
  try {
64731
65431
  const routes = routeMap ?? dataContext?.routes;
64732
65432
  const hydratedContent = routes ? hydrateManifestLinks(definition.manifest, data, routes, dataContext?.pagesByIdentifier, dataContext?.entriesByIdentifier) : data;
64733
65433
  const resolved = resolveBlockData$1(block.id, definition.dataLoaders, dataContext, routes);
64734
65434
  const hasResolvedData = !!resolved && typeof resolved === "object" && Object.keys(resolved).length > 0;
64735
- const baseProps = {
65435
+ const baseProps = buildBlockOverrideProps({
65436
+ block,
64736
65437
  content: hydratedContent,
64737
- ...overrideThemeProps(themeResources),
64738
65438
  data: resolved,
64739
- siteId: dataContext?.siteId,
64740
- apiBaseUrl: dataContext?.apiBaseUrl,
64741
- portalToken: dataContext?.portalToken ?? null,
64742
- previewToken: dataContext?.previewToken ?? null,
64743
- stage: dataContext?.previewStage,
64744
- occurrenceContext: dataContext?.occurrenceContext ?? null,
64745
- entrySubrouteContext: dataContext?.entrySubrouteContext ?? null,
64746
- contentEntry: dataContext?.contentEntry ?? null,
64747
- pageSurface: dataContext?.pageSurface ?? null,
64748
- subject: dataContext?.subject ?? null,
64749
- supabaseUrl: dataContext?.supabaseUrl,
64750
- imageResolveContext: resolvedImageResolveContext,
64751
- blockId: block.id ?? null,
64752
- blockKind: block.kind
64753
- };
64754
- baseProps.bookingTimeZone = resolveBookingTimeZone(dataContext);
65439
+ themeResources,
65440
+ dataContext,
65441
+ imageResolveContext: resolvedImageResolveContext
65442
+ });
64755
65443
  let rendered;
64756
65444
  if (OverrideComponent) rendered = /* @__PURE__ */ jsx(OverrideComponent, { ...baseProps });
64757
65445
  else {
@@ -64798,39 +65486,11 @@ const MemoBlockSection = React$1.memo(function MemoBlockSection({ themeResources
64798
65486
  hasResolvedData
64799
65487
  });
64800
65488
  } catch {
64801
- const rendered = block.presentationAdmission.kind === "editor" ? fallback(`Unable to render block kind "${block.kind}".`) : fallback(`Invalid content for block kind "${block.kind}".`);
64802
- return finish(wrapBlock && block.id ? /* @__PURE__ */ jsx(Fragment, { children: wrapBlock(block.id, rendered) }) : /* @__PURE__ */ jsx(Fragment, { children: rendered }), {
65489
+ return finish(withBlockWrapper(diagnostic(block.presentationAdmission.kind === "editor" ? `Unable to render block kind "${block.kind}".` : `Invalid content for block kind "${block.kind}".`, { outcome: "exception" })), {
64803
65490
  outcome: "exception",
64804
65491
  override: false
64805
65492
  });
64806
65493
  }
64807
- }, (prev, next) => {
64808
- return prev.block === next.block && prev.registry === next.registry && prev.dataContext === next.dataContext && prev.themeResources === next.themeResources && prev.routeMap === next.routeMap && prev.block.presentationAdmission === next.block.presentationAdmission && prev.wrapBlock === next.wrapBlock && prev.blockOverrides === next.blockOverrides && prev.sdkConfig === next.sdkConfig && prev.customBlocks === next.customBlocks && prev.imageResolveContext === next.imageResolveContext && prev.previewAuthoring === next.previewAuthoring;
64809
- });
64810
- function fallback(message) {
64811
- return /* @__PURE__ */ jsx("div", {
64812
- className: "rb-p-6 rb-caption",
64813
- style: textColorStyle("mutedText"),
64814
- children: message
64815
- });
64816
- }
64817
- function shouldDebugCustomBlocks() {
64818
- try {
64819
- return globalThis.process?.env?.DEBUG_CUSTOM_BLOCKS === "1";
64820
- } catch {
64821
- return false;
64822
- }
64823
- }
64824
- /**
64825
- * Resolves block content based on stage priority.
64826
- * For draft/preview: draftContent takes priority over content.
64827
- * For published: content is the source of truth.
64828
- * Returns empty object if no content exists (validator will handle placeholders if needed).
64829
- */
64830
- function getRenderableContent(block, previewStage = "published") {
64831
- const source = previewStage === "preview" && block.draftContent ? block.draftContent : block.content ?? {};
64832
- if (!isObjectRecord$1(source)) return {};
64833
- return source;
64834
65494
  }
64835
65495
  //#endregion
64836
65496
  //#region ../blocks/src/structure/schema.ts
@@ -74804,6 +75464,21 @@ const containerResponsiveThemeCss = `/*
74804
75464
  outline-offset: 2px;
74805
75465
  }
74806
75466
 
75467
+ /* Event titles always link to the event page, so a card or row whose CTA is
75468
+ hidden (sold out, closed) is never a dead end (issue 1372). The link inherits
75469
+ the heading's colour and only reveals itself on hover or keyboard focus.
75470
+ Shared by EventCard, EventCompactRow and the offerings-preview course cards via \`.event-title-link\`. */
75471
+ :where(.theme-scope) .event-title-link {
75472
+ color: inherit;
75473
+ text-decoration: none;
75474
+ }
75475
+
75476
+ :where(.theme-scope) .event-title-link:hover,
75477
+ :where(.theme-scope) .event-title-link:focus-visible {
75478
+ text-decoration: underline;
75479
+ text-underline-offset: 0.12em;
75480
+ }
75481
+
74807
75482
  /* =============================================================================
74808
75483
  * Block: Carousel Navigation (Phase 3 client component migration)
74809
75484
  * =============================================================================
@@ -80151,6 +80826,50 @@ z$1.object({
80151
80826
  title: z$1.string(),
80152
80827
  slug: z$1.string().min(1)
80153
80828
  });
80829
+ z$1.object({
80830
+ signedUrl: z$1.string().url(),
80831
+ token: z$1.string().min(1),
80832
+ storagePath: z$1.string().min(1)
80833
+ }).strict();
80834
+ const backupManifestSchema = z$1.object({
80835
+ siteId: z$1.string().min(1),
80836
+ siteSlug: z$1.string().min(1),
80837
+ siteTitle: z$1.string(),
80838
+ exportedAt: z$1.string().min(1),
80839
+ tableCount: z$1.number().int().nonnegative(),
80840
+ totalRecords: z$1.number().int().nonnegative(),
80841
+ pageCount: z$1.number().int().nonnegative(),
80842
+ blockCount: z$1.number().int().nonnegative(),
80843
+ contentEntryCount: z$1.number().int().nonnegative(),
80844
+ formCount: z$1.number().int().nonnegative(),
80845
+ navigationMenuCount: z$1.number().int().nonnegative(),
80846
+ mediaCount: z$1.number().int().nonnegative(),
80847
+ mediaBytes: z$1.number().int().nonnegative()
80848
+ }).strict();
80849
+ z$1.object({
80850
+ ok: z$1.literal(true),
80851
+ manifest: backupManifestSchema,
80852
+ storagePath: z$1.string().min(1)
80853
+ }).strict();
80854
+ z$1.object({
80855
+ ok: z$1.literal(true),
80856
+ siteId: brandedIdSchema(parseSiteId, "site id"),
80857
+ slug: z$1.string().min(1),
80858
+ title: z$1.string(),
80859
+ counts: z$1.record(z$1.string(), z$1.object({
80860
+ created: z$1.number().int().nonnegative(),
80861
+ updated: z$1.number().int().nonnegative(),
80862
+ skipped: z$1.number().int().nonnegative(),
80863
+ failed: z$1.number().int().nonnegative()
80864
+ }).strict()),
80865
+ warnings: z$1.array(z$1.string()),
80866
+ errors: z$1.array(z$1.object({
80867
+ table: z$1.string(),
80868
+ id: z$1.string(),
80869
+ error: z$1.string()
80870
+ }).strict())
80871
+ }).strict();
80872
+ z$1.object({ deleted: z$1.boolean() }).strict();
80154
80873
  const apiKeyIdSchema = brandedIdSchema(parseApiKeyId, "API key id");
80155
80874
  const standardKeyIdsSchema = z$1.object({
80156
80875
  live: apiKeyIdSchema.optional(),
@@ -83942,9 +84661,7 @@ function admitMaterializedSdkTemplateBlockContent(input) {
83942
84661
  const baseContent = sanitizeSdkRuntimeBlockContent(input.block.blockKind, input.block.content ?? {});
83943
84662
  const bindings = input.block.bindings ?? {};
83944
84663
  const materialized = input.block.scope === "entry" && Object.keys(bindings).length > 0 ? applyBindingsToContent(baseContent, bindings, input.bindingContext) : baseContent;
83945
- const contract = compileManifestContentContract(input.manifestOrFields);
83946
- const stored = admitStoredManifestContent(contract, materialized);
83947
- return stored.ok ? deriveRenderableManifestContent(contract, stored.value, (content) => content) : stored;
84664
+ return admitTransportedRenderableManifestContent(compileManifestContentContract(input.manifestOrFields), materialized);
83948
84665
  }
83949
84666
  function createTemplateRenderTarget(source, templateId) {
83950
84667
  switch (source.kind) {
@@ -84076,13 +84793,7 @@ function admitEntrySlotBlock(block, customBlocks) {
84076
84793
  if (transportedAdmission) return invalidRenderableBlock(base, transportedAdmission.error);
84077
84794
  const manifest = resolveSdkBlockManifest(block.kind, customBlocks);
84078
84795
  if (manifest === null) return invalidRenderableBlock(base, { kind: "manifest_unavailable" });
84079
- const contract = compileManifestContentContract(manifest);
84080
- const stored = admitStoredManifestContent(contract, block.content ?? {});
84081
- if (!stored.ok) return invalidRenderableBlock(base, {
84082
- kind: "invalid_manifest_content",
84083
- error: stored.error
84084
- });
84085
- const renderable = deriveRenderableManifestContent(contract, stored.value, (content) => content);
84796
+ const renderable = admitTransportedRenderableManifestContent(compileManifestContentContract(manifest), block.content ?? {});
84086
84797
  return renderable.ok ? {
84087
84798
  ...base,
84088
84799
  admission: {
@@ -85783,7 +86494,7 @@ var CircuitOpenError = class extends Error {
85783
86494
  * 1. This constant
85784
86495
  * 2. The "version" field in package.json
85785
86496
  */
85786
- const SDK_VERSION = "0.136.0";
86497
+ const SDK_VERSION = "0.137.0";
85787
86498
  //#endregion
85788
86499
  //#region src/next/content-load-failure.ts
85789
86500
  const SdkContentLoadError = RiverbankContentLoadError;