@medalsocial/booking 0.2.1 → 0.3.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 (53) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +21 -3
  3. package/dist/core/attempt-store.d.mts +1 -0
  4. package/dist/core/attempt-store.mjs +13 -0
  5. package/dist/core/attempt-store.mjs.map +1 -1
  6. package/dist/core/config.d.mts +37 -0
  7. package/dist/core/config.mjs +29 -0
  8. package/dist/core/config.mjs.map +1 -1
  9. package/dist/core/machine.d.mts +27 -1
  10. package/dist/core/machine.mjs +52 -4
  11. package/dist/core/machine.mjs.map +1 -1
  12. package/dist/next/medal.d.mts +8 -1
  13. package/dist/next/medal.mjs +16 -5
  14. package/dist/next/medal.mjs.map +1 -1
  15. package/dist/next/portal/medal-portal.mjs +1 -62
  16. package/dist/next/portal/medal-portal.mjs.map +1 -1
  17. package/dist/next/redact.mjs +76 -0
  18. package/dist/next/redact.mjs.map +1 -0
  19. package/dist/next/routes/create.mjs +67 -9
  20. package/dist/next/routes/create.mjs.map +1 -1
  21. package/dist/next/routes/shared.mjs +3 -0
  22. package/dist/next/routes/shared.mjs.map +1 -1
  23. package/dist/react/BookingWizard.mjs +139 -11
  24. package/dist/react/BookingWizard.mjs.map +1 -1
  25. package/dist/react/LoginSheet.d.mts +19 -1
  26. package/dist/react/LoginSheet.mjs +15 -1
  27. package/dist/react/LoginSheet.mjs.map +1 -1
  28. package/dist/react/Provider.d.mts +2 -1
  29. package/dist/react/Provider.mjs.map +1 -1
  30. package/dist/react/actions.d.mts +16 -5
  31. package/dist/react/actions.mjs +5 -1
  32. package/dist/react/actions.mjs.map +1 -1
  33. package/dist/react/index.d.mts +2 -2
  34. package/dist/react/kit.d.mts +6 -1
  35. package/dist/react/kit.mjs +4 -3
  36. package/dist/react/kit.mjs.map +1 -1
  37. package/dist/react/labels/login.d.mts +2 -0
  38. package/dist/react/labels/login.mjs +4 -2
  39. package/dist/react/labels/login.mjs.map +1 -1
  40. package/dist/react/labels/screens.mjs +58 -2
  41. package/dist/react/labels/screens.mjs.map +1 -1
  42. package/dist/react/labels/wizard.d.mts +8 -0
  43. package/dist/react/labels/wizard.mjs +6 -0
  44. package/dist/react/labels/wizard.mjs.map +1 -1
  45. package/dist/react/screen-labels.mjs +5 -0
  46. package/dist/react/screen-labels.mjs.map +1 -1
  47. package/dist/react/useBooking.d.mts +19 -1
  48. package/dist/react/useBooking.mjs +269 -42
  49. package/dist/react/useBooking.mjs.map +1 -1
  50. package/dist/react/wizard/adapters.mjs +66 -3
  51. package/dist/react/wizard/adapters.mjs.map +1 -1
  52. package/dist/react/wizard/index.d.mts +2 -2
  53. package/package.json +4 -4
@@ -1 +1 @@
1
- {"version":3,"file":"Provider.mjs","names":[],"sources":["../../src/react/Provider.tsx"],"sourcesContent":["/**\n * `<BookingProvider>` — one config, one label pack and one set of overrides\n * for every booking piece under it (the wizard, a sticky bar, a hero chip, the\n * manage page), so they agree about the business without each being told.\n *\n * Every component also takes the same four props itself; a prop wins over the\n * provider, and a component outside any provider needs at least `config`.\n */\n\nimport type {\n AccountCardSlot,\n AddChildSheetSlot,\n BookingCardProps,\n BookingSkeletonSlot,\n ChildCardProps,\n ChildCardsSlot,\n ConfirmationSlot,\n DataControlsSlot,\n DayChipProps,\n DetailsScreenSlot,\n FamilyChipProps,\n FamilyEditorSlot,\n FamilyMemberCardProps,\n LoginPanelSlot,\n LoginSheetSlot,\n LogoutButtonSlot,\n ManageScreenSlot,\n OtpSlotsSlot,\n PartyLineProps,\n PersonCardProps,\n PortalShellSlot,\n PortalUnreachableSlot,\n ProfileFormSlot,\n RebookCardProps,\n RebookCardsSlot,\n ServiceCardProps,\n ServiceScreenSlot,\n SlotClassNames,\n StylistCardProps,\n StylistScreenSlot,\n SummaryBarSlot,\n TimeChipProps,\n TimeScreenSlot,\n UpcomingBookingsSlot,\n VippsButtonSlot,\n VippsLinkRowSlot,\n VisitHistorySlot,\n VisitRowProps,\n WhoScreenSlot,\n} from '@medalsocial/meda/booking';\nimport { type ComponentType, createContext, type ReactNode, useContext, useMemo } from 'react';\nimport type { BookingConfig } from '../core/config';\nimport { type BookingKit, createBookingKit } from './kit';\nimport type { BookingLabels, BookingLabelsInput } from './labels';\n\n/** Per-screen slot classes (see each meda screen's `…Slot` type for the slots). */\nexport interface BookingClassNames {\n wizard?: SlotClassNames<'root' | 'header' | 'back' | 'progress' | 'alert'>;\n who?: SlotClassNames<WhoScreenSlot>;\n addChild?: SlotClassNames<AddChildSheetSlot>;\n service?: SlotClassNames<ServiceScreenSlot>;\n stylist?: SlotClassNames<StylistScreenSlot>;\n time?: SlotClassNames<TimeScreenSlot>;\n details?: SlotClassNames<DetailsScreenSlot>;\n summary?: SlotClassNames<SummaryBarSlot>;\n confirmation?: SlotClassNames<ConfirmationSlot>;\n skeleton?: SlotClassNames<BookingSkeletonSlot>;\n manage?: SlotClassNames<ManageScreenSlot>;\n loginSheet?: SlotClassNames<LoginSheetSlot>;\n loginPanel?: SlotClassNames<LoginPanelSlot>;\n otp?: SlotClassNames<OtpSlotsSlot>;\n vipps?: SlotClassNames<VippsButtonSlot>;\n portalShell?: SlotClassNames<PortalShellSlot>;\n portalUnreachable?: SlotClassNames<PortalUnreachableSlot>;\n upcoming?: SlotClassNames<UpcomingBookingsSlot>;\n history?: SlotClassNames<VisitHistorySlot>;\n rebook?: SlotClassNames<RebookCardsSlot>;\n childCards?: SlotClassNames<ChildCardsSlot>;\n accountCard?: SlotClassNames<AccountCardSlot>;\n familyEditor?: SlotClassNames<FamilyEditorSlot>;\n profileForm?: SlotClassNames<ProfileFormSlot>;\n dataControls?: SlotClassNames<DataControlsSlot>;\n logout?: SlotClassNames<LogoutButtonSlot>;\n vippsLink?: SlotClassNames<VippsLinkRowSlot>;\n}\n\n/** Card-level renderers, by name; each one is handed to the screen that draws it. */\nexport interface BookingComponents {\n PersonCard?: ComponentType<PersonCardProps>;\n ServiceCard?: ComponentType<ServiceCardProps>;\n StylistCard?: ComponentType<StylistCardProps>;\n DayChip?: ComponentType<DayChipProps>;\n TimeChip?: ComponentType<TimeChipProps>;\n FamilyChip?: ComponentType<FamilyChipProps>;\n PartyLine?: ComponentType<PartyLineProps>;\n BookingCard?: ComponentType<BookingCardProps>;\n VisitRow?: ComponentType<VisitRowProps>;\n RebookCard?: ComponentType<RebookCardProps>;\n ChildCard?: ComponentType<ChildCardProps>;\n FamilyMemberCard?: ComponentType<FamilyMemberCardProps>;\n}\n\n/** The four props every booking component and the provider take. */\nexport interface BookingOverrides {\n /** The site's resolved config (`resolveBookingConfig` from `/core`, run on the server). */\n config?: Readonly<BookingConfig>;\n /**\n * Words over `config.labels`. The pack itself is resolved on the server —\n * `mergeLabels(locale, siteWords)` from `@medalsocial/booking/react/shared`\n * — so the built-in packs never ship in the browser bundle.\n */\n labels?: BookingLabelsInput;\n classNames?: BookingClassNames;\n components?: BookingComponents;\n}\n\ninterface BookingContextValue {\n config: Readonly<BookingConfig> | undefined;\n labels: BookingLabelsInput | undefined;\n classNames: BookingClassNames;\n components: BookingComponents;\n}\n\nconst BookingContext = createContext<BookingContextValue | null>(null);\n\nexport interface BookingProviderProps extends BookingOverrides {\n children?: ReactNode;\n}\n\nexport function BookingProvider({\n config,\n labels,\n classNames,\n components,\n children,\n}: BookingProviderProps) {\n const parent = useContext(BookingContext);\n const value = useMemo<BookingContextValue>(\n () => ({\n config: config ?? parent?.config,\n labels:\n parent?.labels && labels ? { ...parent.labels, ...labels } : (labels ?? parent?.labels),\n classNames: mergeClassNames(parent?.classNames, classNames),\n components: { ...parent?.components, ...components },\n }),\n [config, labels, classNames, components, parent]\n );\n return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;\n}\n\n/** What a component renders with: the kit, and the overrides that apply to it. */\nexport interface ResolvedBooking {\n kit: BookingKit;\n classNames: BookingClassNames;\n components: BookingComponents;\n}\n\n/**\n * The kit for a component's own props over the nearest provider. Memoised on\n * the inputs, so a re-render with the same props reuses the same clock,\n * machine and label pack.\n */\nexport function useBookingKit(\n props: BookingOverrides,\n contact?: Partial<Pick<BookingConfig['contact'], 'phone' | 'address'>>\n): ResolvedBooking {\n const context = useContext(BookingContext);\n const base = props.config ?? context?.config;\n if (base === undefined) {\n throw new Error(\n 'A booking component needs a `config`: pass it, or render it inside <BookingProvider config={…}>.'\n );\n }\n const config = withContact(base, contact);\n const contextLabels = context?.labels;\n const labels = useMemo(\n () => mergedLabels(config, contextLabels, props.labels),\n [config, contextLabels, props.labels]\n );\n const kit = useMemo(() => createBookingKit(config, labels), [config, labels]);\n const contextClassNames = context?.classNames;\n const contextComponents = context?.components;\n const classNames = useMemo(\n () => mergeClassNames(contextClassNames, props.classNames),\n [contextClassNames, props.classNames]\n );\n const components = useMemo(\n () => ({ ...contextComponents, ...props.components }),\n [contextComponents, props.components]\n );\n return { kit, classNames, components };\n}\n\nconst WITH_CONTACT = new WeakMap<object, Map<string, Readonly<BookingConfig>>>();\n\n/**\n * The config with a per-request contact over its own (a page that reads the\n * phone and address from a CMS), one object per pair so the kit is reused.\n */\nfunction withContact(\n config: Readonly<BookingConfig>,\n contact: Partial<Pick<BookingConfig['contact'], 'phone' | 'address'>> | undefined\n): Readonly<BookingConfig> {\n if (contact === undefined) return config;\n const merged = { ...config.contact, ...contact };\n const key = JSON.stringify([merged.phone, merged.address]);\n let byKey = WITH_CONTACT.get(config);\n if (!byKey) {\n byKey = new Map();\n WITH_CONTACT.set(config, byKey);\n }\n let derived = byKey.get(key);\n if (!derived) {\n derived = Object.freeze({ ...config, contact: Object.freeze(merged) });\n byKey.set(key, derived);\n }\n return derived;\n}\n\n/** Stands in for «no labels» as a cache key. */\nconst NO_LABELS: BookingLabelsInput = {};\nconst MERGED = new WeakMap<\n object,\n WeakMap<BookingLabelsInput, WeakMap<BookingLabelsInput, Readonly<BookingLabels>>>\n>();\n\n/**\n * The pack a component renders with, cached on its three inputs, so every\n * component that shares a config and the same label objects shares one pack\n * — and so one kit (`createBookingKit` caches on the pack's identity).\n */\nfunction mergedLabels(\n config: Readonly<BookingConfig>,\n outer: BookingLabelsInput | undefined,\n inner: BookingLabelsInput | undefined\n): Readonly<BookingLabels> {\n let byOuter = MERGED.get(config);\n if (!byOuter) {\n byOuter = new WeakMap();\n MERGED.set(config, byOuter);\n }\n let byInner = byOuter.get(outer ?? NO_LABELS);\n if (!byInner) {\n byInner = new WeakMap();\n byOuter.set(outer ?? NO_LABELS, byInner);\n }\n const known = byInner.get(inner ?? NO_LABELS);\n if (known) return known;\n const merged = {\n ...config.labels,\n ...outer,\n ...inner,\n // The consent sentence is the one recorded with its version, so the box shows it.\n ...(config.consent.marketing\n ? { 'details.marketing.text': config.consent.marketing.text }\n : {}),\n } as BookingLabels;\n if (merged['who.heading'] === undefined) {\n console.warn(\n '[@medalsocial/booking] No label pack: pass `labels: mergeLabels(locale, …)` from ' +\n \"'@medalsocial/booking/react/shared' into the config or the provider.\"\n );\n }\n byInner.set(inner ?? NO_LABELS, merged);\n return merged;\n}\n\n/** Screen by screen: a component's own slot classes over the provider's. */\nfunction mergeClassNames(\n outer: BookingClassNames | undefined,\n inner: BookingClassNames | undefined\n): BookingClassNames {\n if (!outer) return inner ?? {};\n if (!inner) return outer;\n const merged: Record<string, Record<string, string> | undefined> = { ...outer };\n for (const [screen, slots] of Object.entries(inner)) {\n merged[screen] = { ...(outer as Record<string, Record<string, string>>)[screen], ...slots };\n }\n return merged as BookingClassNames;\n}\n"],"mappings":";;;;;AA2HA,MAAM,iBAAiB,cAA0C,IAAI;AAMrE,SAAgB,gBAAgB,EAC9B,QACA,QACA,YACA,YACA,YACuB;CACvB,MAAM,SAAS,WAAW,cAAc;CACxC,MAAM,QAAQ,eACL;EACL,QAAQ,UAAU,QAAQ;EAC1B,QACE,QAAQ,UAAU,SAAS;GAAE,GAAG,OAAO;GAAQ,GAAG;EAAO,IAAK,UAAU,QAAQ;EAClF,YAAY,gBAAgB,QAAQ,YAAY,UAAU;EAC1D,YAAY;GAAE,GAAG,QAAQ;GAAY,GAAG;EAAW;CACrD,IACA;EAAC;EAAQ;EAAQ;EAAY;EAAY;CAAM,CACjD;CACA,OAAO,oBAAC,eAAe,UAAhB;EAAgC;EAAQ;CAAkC,CAAA;AACnF;;;;;;AAcA,SAAgB,cACd,OACA,SACiB;CACjB,MAAM,UAAU,WAAW,cAAc;CACzC,MAAM,OAAO,MAAM,UAAU,SAAS;CACtC,IAAI,SAAS,KAAA,GACX,MAAM,IAAI,MACR,kGACF;CAEF,MAAM,SAAS,YAAY,MAAM,OAAO;CACxC,MAAM,gBAAgB,SAAS;CAC/B,MAAM,SAAS,cACP,aAAa,QAAQ,eAAe,MAAM,MAAM,GACtD;EAAC;EAAQ;EAAe,MAAM;CAAM,CACtC;CACA,MAAM,MAAM,cAAc,iBAAiB,QAAQ,MAAM,GAAG,CAAC,QAAQ,MAAM,CAAC;CAC5E,MAAM,oBAAoB,SAAS;CACnC,MAAM,oBAAoB,SAAS;CASnC,OAAO;EAAE;EAAK,YARK,cACX,gBAAgB,mBAAmB,MAAM,UAAU,GACzD,CAAC,mBAAmB,MAAM,UAAU,CAMf;EAAG,YAJP,eACV;GAAE,GAAG;GAAmB,GAAG,MAAM;EAAW,IACnD,CAAC,mBAAmB,MAAM,UAAU,CAEH;CAAE;AACvC;AAEA,MAAM,+BAAe,IAAI,QAAsD;;;;;AAM/E,SAAS,YACP,QACA,SACyB;CACzB,IAAI,YAAY,KAAA,GAAW,OAAO;CAClC,MAAM,SAAS;EAAE,GAAG,OAAO;EAAS,GAAG;CAAQ;CAC/C,MAAM,MAAM,KAAK,UAAU,CAAC,OAAO,OAAO,OAAO,OAAO,CAAC;CACzD,IAAI,QAAQ,aAAa,IAAI,MAAM;CACnC,IAAI,CAAC,OAAO;EACV,wBAAQ,IAAI,IAAI;EAChB,aAAa,IAAI,QAAQ,KAAK;CAChC;CACA,IAAI,UAAU,MAAM,IAAI,GAAG;CAC3B,IAAI,CAAC,SAAS;EACZ,UAAU,OAAO,OAAO;GAAE,GAAG;GAAQ,SAAS,OAAO,OAAO,MAAM;EAAE,CAAC;EACrE,MAAM,IAAI,KAAK,OAAO;CACxB;CACA,OAAO;AACT;;AAGA,MAAM,YAAgC,CAAC;AACvC,MAAM,yBAAS,IAAI,QAGjB;;;;;;AAOF,SAAS,aACP,QACA,OACA,OACyB;CACzB,IAAI,UAAU,OAAO,IAAI,MAAM;CAC/B,IAAI,CAAC,SAAS;EACZ,0BAAU,IAAI,QAAQ;EACtB,OAAO,IAAI,QAAQ,OAAO;CAC5B;CACA,IAAI,UAAU,QAAQ,IAAI,SAAS,SAAS;CAC5C,IAAI,CAAC,SAAS;EACZ,0BAAU,IAAI,QAAQ;EACtB,QAAQ,IAAI,SAAS,WAAW,OAAO;CACzC;CACA,MAAM,QAAQ,QAAQ,IAAI,SAAS,SAAS;CAC5C,IAAI,OAAO,OAAO;CAClB,MAAM,SAAS;EACb,GAAG,OAAO;EACV,GAAG;EACH,GAAG;EAEH,GAAI,OAAO,QAAQ,YACf,EAAE,0BAA0B,OAAO,QAAQ,UAAU,KAAK,IAC1D,CAAC;CACP;CACA,IAAI,OAAO,mBAAmB,KAAA,GAC5B,QAAQ,KACN,uJAEF;CAEF,QAAQ,IAAI,SAAS,WAAW,MAAM;CACtC,OAAO;AACT;;AAGA,SAAS,gBACP,OACA,OACmB;CACnB,IAAI,CAAC,OAAO,OAAO,SAAS,CAAC;CAC7B,IAAI,CAAC,OAAO,OAAO;CACnB,MAAM,SAA6D,EAAE,GAAG,MAAM;CAC9E,KAAK,MAAM,CAAC,QAAQ,UAAU,OAAO,QAAQ,KAAK,GAChD,OAAO,UAAU;EAAE,GAAI,MAAiD;EAAS,GAAG;CAAM;CAE5F,OAAO;AACT"}
1
+ {"version":3,"file":"Provider.mjs","names":[],"sources":["../../src/react/Provider.tsx"],"sourcesContent":["/**\n * `<BookingProvider>` — one config, one label pack and one set of overrides\n * for every booking piece under it (the wizard, a sticky bar, a hero chip, the\n * manage page), so they agree about the business without each being told.\n *\n * Every component also takes the same four props itself; a prop wins over the\n * provider, and a component outside any provider needs at least `config`.\n */\n\nimport type {\n AccountCardSlot,\n AddChildSheetSlot,\n BookingCardProps,\n BookingRecapSlot,\n BookingSkeletonSlot,\n ChildCardProps,\n ChildCardsSlot,\n ConfirmationSlot,\n DataControlsSlot,\n DayChipProps,\n DetailsScreenSlot,\n FamilyChipProps,\n FamilyEditorSlot,\n FamilyMemberCardProps,\n LoginPanelSlot,\n LoginSheetSlot,\n LogoutButtonSlot,\n ManageScreenSlot,\n OtpSlotsSlot,\n PartyLineProps,\n PersonCardProps,\n PortalShellSlot,\n PortalUnreachableSlot,\n ProfileFormSlot,\n RebookCardProps,\n RebookCardsSlot,\n ServiceCardProps,\n ServiceScreenSlot,\n SlotClassNames,\n StylistCardProps,\n StylistScreenSlot,\n SummaryBarSlot,\n TimeChipProps,\n TimeScreenSlot,\n UpcomingBookingsSlot,\n VippsButtonSlot,\n VippsLinkRowSlot,\n VisitHistorySlot,\n VisitRowProps,\n WhoScreenSlot,\n} from '@medalsocial/meda/booking';\nimport { type ComponentType, createContext, type ReactNode, useContext, useMemo } from 'react';\nimport type { BookingConfig } from '../core/config';\nimport { type BookingKit, createBookingKit } from './kit';\nimport type { BookingLabels, BookingLabelsInput } from './labels';\n\n/** Per-screen slot classes (see each meda screen's `…Slot` type for the slots). */\nexport interface BookingClassNames {\n wizard?: SlotClassNames<'root' | 'header' | 'back' | 'progress' | 'alert'>;\n who?: SlotClassNames<WhoScreenSlot>;\n addChild?: SlotClassNames<AddChildSheetSlot>;\n service?: SlotClassNames<ServiceScreenSlot>;\n stylist?: SlotClassNames<StylistScreenSlot>;\n time?: SlotClassNames<TimeScreenSlot>;\n details?: SlotClassNames<DetailsScreenSlot>;\n recap?: SlotClassNames<BookingRecapSlot>;\n summary?: SlotClassNames<SummaryBarSlot>;\n confirmation?: SlotClassNames<ConfirmationSlot>;\n skeleton?: SlotClassNames<BookingSkeletonSlot>;\n manage?: SlotClassNames<ManageScreenSlot>;\n loginSheet?: SlotClassNames<LoginSheetSlot>;\n loginPanel?: SlotClassNames<LoginPanelSlot>;\n otp?: SlotClassNames<OtpSlotsSlot>;\n vipps?: SlotClassNames<VippsButtonSlot>;\n portalShell?: SlotClassNames<PortalShellSlot>;\n portalUnreachable?: SlotClassNames<PortalUnreachableSlot>;\n upcoming?: SlotClassNames<UpcomingBookingsSlot>;\n history?: SlotClassNames<VisitHistorySlot>;\n rebook?: SlotClassNames<RebookCardsSlot>;\n childCards?: SlotClassNames<ChildCardsSlot>;\n accountCard?: SlotClassNames<AccountCardSlot>;\n familyEditor?: SlotClassNames<FamilyEditorSlot>;\n profileForm?: SlotClassNames<ProfileFormSlot>;\n dataControls?: SlotClassNames<DataControlsSlot>;\n logout?: SlotClassNames<LogoutButtonSlot>;\n vippsLink?: SlotClassNames<VippsLinkRowSlot>;\n}\n\n/** Card-level renderers, by name; each one is handed to the screen that draws it. */\nexport interface BookingComponents {\n PersonCard?: ComponentType<PersonCardProps>;\n ServiceCard?: ComponentType<ServiceCardProps>;\n StylistCard?: ComponentType<StylistCardProps>;\n DayChip?: ComponentType<DayChipProps>;\n TimeChip?: ComponentType<TimeChipProps>;\n FamilyChip?: ComponentType<FamilyChipProps>;\n PartyLine?: ComponentType<PartyLineProps>;\n BookingCard?: ComponentType<BookingCardProps>;\n VisitRow?: ComponentType<VisitRowProps>;\n RebookCard?: ComponentType<RebookCardProps>;\n ChildCard?: ComponentType<ChildCardProps>;\n FamilyMemberCard?: ComponentType<FamilyMemberCardProps>;\n}\n\n/** The four props every booking component and the provider take. */\nexport interface BookingOverrides {\n /** The site's resolved config (`resolveBookingConfig` from `/core`, run on the server). */\n config?: Readonly<BookingConfig>;\n /**\n * Words over `config.labels`. The pack itself is resolved on the server —\n * `mergeLabels(locale, siteWords)` from `@medalsocial/booking/react/shared`\n * — so the built-in packs never ship in the browser bundle.\n */\n labels?: BookingLabelsInput;\n classNames?: BookingClassNames;\n components?: BookingComponents;\n}\n\ninterface BookingContextValue {\n config: Readonly<BookingConfig> | undefined;\n labels: BookingLabelsInput | undefined;\n classNames: BookingClassNames;\n components: BookingComponents;\n}\n\nconst BookingContext = createContext<BookingContextValue | null>(null);\n\nexport interface BookingProviderProps extends BookingOverrides {\n children?: ReactNode;\n}\n\nexport function BookingProvider({\n config,\n labels,\n classNames,\n components,\n children,\n}: BookingProviderProps) {\n const parent = useContext(BookingContext);\n const value = useMemo<BookingContextValue>(\n () => ({\n config: config ?? parent?.config,\n labels:\n parent?.labels && labels ? { ...parent.labels, ...labels } : (labels ?? parent?.labels),\n classNames: mergeClassNames(parent?.classNames, classNames),\n components: { ...parent?.components, ...components },\n }),\n [config, labels, classNames, components, parent]\n );\n return <BookingContext.Provider value={value}>{children}</BookingContext.Provider>;\n}\n\n/** What a component renders with: the kit, and the overrides that apply to it. */\nexport interface ResolvedBooking {\n kit: BookingKit;\n classNames: BookingClassNames;\n components: BookingComponents;\n}\n\n/**\n * The kit for a component's own props over the nearest provider. Memoised on\n * the inputs, so a re-render with the same props reuses the same clock,\n * machine and label pack.\n */\nexport function useBookingKit(\n props: BookingOverrides,\n contact?: Partial<Pick<BookingConfig['contact'], 'phone' | 'address'>>\n): ResolvedBooking {\n const context = useContext(BookingContext);\n const base = props.config ?? context?.config;\n if (base === undefined) {\n throw new Error(\n 'A booking component needs a `config`: pass it, or render it inside <BookingProvider config={…}>.'\n );\n }\n const config = withContact(base, contact);\n const contextLabels = context?.labels;\n const labels = useMemo(\n () => mergedLabels(config, contextLabels, props.labels),\n [config, contextLabels, props.labels]\n );\n const kit = useMemo(() => createBookingKit(config, labels), [config, labels]);\n const contextClassNames = context?.classNames;\n const contextComponents = context?.components;\n const classNames = useMemo(\n () => mergeClassNames(contextClassNames, props.classNames),\n [contextClassNames, props.classNames]\n );\n const components = useMemo(\n () => ({ ...contextComponents, ...props.components }),\n [contextComponents, props.components]\n );\n return { kit, classNames, components };\n}\n\nconst WITH_CONTACT = new WeakMap<object, Map<string, Readonly<BookingConfig>>>();\n\n/**\n * The config with a per-request contact over its own (a page that reads the\n * phone and address from a CMS), one object per pair so the kit is reused.\n */\nfunction withContact(\n config: Readonly<BookingConfig>,\n contact: Partial<Pick<BookingConfig['contact'], 'phone' | 'address'>> | undefined\n): Readonly<BookingConfig> {\n if (contact === undefined) return config;\n const merged = { ...config.contact, ...contact };\n const key = JSON.stringify([merged.phone, merged.address]);\n let byKey = WITH_CONTACT.get(config);\n if (!byKey) {\n byKey = new Map();\n WITH_CONTACT.set(config, byKey);\n }\n let derived = byKey.get(key);\n if (!derived) {\n derived = Object.freeze({ ...config, contact: Object.freeze(merged) });\n byKey.set(key, derived);\n }\n return derived;\n}\n\n/** Stands in for «no labels» as a cache key. */\nconst NO_LABELS: BookingLabelsInput = {};\nconst MERGED = new WeakMap<\n object,\n WeakMap<BookingLabelsInput, WeakMap<BookingLabelsInput, Readonly<BookingLabels>>>\n>();\n\n/**\n * The pack a component renders with, cached on its three inputs, so every\n * component that shares a config and the same label objects shares one pack\n * — and so one kit (`createBookingKit` caches on the pack's identity).\n */\nfunction mergedLabels(\n config: Readonly<BookingConfig>,\n outer: BookingLabelsInput | undefined,\n inner: BookingLabelsInput | undefined\n): Readonly<BookingLabels> {\n let byOuter = MERGED.get(config);\n if (!byOuter) {\n byOuter = new WeakMap();\n MERGED.set(config, byOuter);\n }\n let byInner = byOuter.get(outer ?? NO_LABELS);\n if (!byInner) {\n byInner = new WeakMap();\n byOuter.set(outer ?? NO_LABELS, byInner);\n }\n const known = byInner.get(inner ?? NO_LABELS);\n if (known) return known;\n const merged = {\n ...config.labels,\n ...outer,\n ...inner,\n // The consent sentence is the one recorded with its version, so the box shows it.\n ...(config.consent.marketing\n ? { 'details.marketing.text': config.consent.marketing.text }\n : {}),\n } as BookingLabels;\n if (merged['who.heading'] === undefined) {\n console.warn(\n '[@medalsocial/booking] No label pack: pass `labels: mergeLabels(locale, …)` from ' +\n \"'@medalsocial/booking/react/shared' into the config or the provider.\"\n );\n }\n byInner.set(inner ?? NO_LABELS, merged);\n return merged;\n}\n\n/** Screen by screen: a component's own slot classes over the provider's. */\nfunction mergeClassNames(\n outer: BookingClassNames | undefined,\n inner: BookingClassNames | undefined\n): BookingClassNames {\n if (!outer) return inner ?? {};\n if (!inner) return outer;\n const merged: Record<string, Record<string, string> | undefined> = { ...outer };\n for (const [screen, slots] of Object.entries(inner)) {\n merged[screen] = { ...(outer as Record<string, Record<string, string>>)[screen], ...slots };\n }\n return merged as BookingClassNames;\n}\n"],"mappings":"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"}
@@ -12,6 +12,16 @@ export type InvalidFailure = {
12
12
  reason: 'invalid';
13
13
  message: string;
14
14
  };
15
+ /**
16
+ * The app itself is limiting how often a code may be asked for (the site's
17
+ * per-IP limiter). Never the package's own answer for MEDAL's per-address
18
+ * throttle: `next/portal` still answers «sent» for that one, because telling
19
+ * the two apart would let anyone confirm which parents are customers.
20
+ */
21
+ export type ThrottledFailure = {
22
+ ok: false;
23
+ reason: 'throttled';
24
+ };
15
25
  /** next-safe-action's envelope. */
16
26
  export interface SafeActionEnvelope<T> {
17
27
  data?: T;
@@ -46,13 +56,14 @@ export interface PersonActionTarget {
46
56
  export interface PortalActions {
47
57
  /**
48
58
  * Ask for a login code. Answers «sent» whether or not the address is known;
49
- * `invalid` only for an address that cannot be one.
59
+ * `invalid` only for an address that cannot be one, `throttled` only when
60
+ * the app's own limiter refused.
50
61
  */
51
62
  startLogin(input: {
52
63
  email: string;
53
64
  }): Promise<ActionAnswer<{
54
65
  status: 'sent';
55
- } | InvalidFailure>>;
66
+ } | InvalidFailure | ThrottledFailure>>;
56
67
  /** Start a Vipps login: a form action that redirects to Vipps, or answers why not. */
57
68
  startVipps?: VippsStartAction;
58
69
  /**
@@ -102,16 +113,16 @@ export type ActionFailure = {
102
113
  * `startLogin`'s answer, read for the login form: `sent` only for a result
103
114
  * that says so — plain or inside the envelope. The action's own refusal of
104
115
  * the address (or zod's) is a bad address; a server error, an empty envelope,
105
- * an answer that is neither, or no answer at all is `unreachable`, so the
116
+ * an app-level `throttled` stays `throttled`; an answer that is neither, or no answer at all is `unreachable`, so the
106
117
  * form never moves on to a code that was not sent.
107
118
  */
108
119
  export declare function readStartLogin(answer: ActionAnswer<{
109
120
  status: 'sent';
110
- } | InvalidFailure> | undefined): {
121
+ } | InvalidFailure | ThrottledFailure> | undefined): {
111
122
  ok: true;
112
123
  } | {
113
124
  ok: false;
114
- reason: 'unreachable' | 'invalidEmail';
125
+ reason: 'unreachable' | 'invalidEmail' | 'throttled';
115
126
  };
116
127
  /**
117
128
  * An action's answer, read: `{ ok: true, value }`, or the failure a form acts
@@ -20,7 +20,7 @@ function isEnvelope(answer) {
20
20
  * `startLogin`'s answer, read for the login form: `sent` only for a result
21
21
  * that says so — plain or inside the envelope. The action's own refusal of
22
22
  * the address (or zod's) is a bad address; a server error, an empty envelope,
23
- * an answer that is neither, or no answer at all is `unreachable`, so the
23
+ * an app-level `throttled` stays `throttled`; an answer that is neither, or no answer at all is `unreachable`, so the
24
24
  * form never moves on to a code that was not sent.
25
25
  */
26
26
  function readStartLogin(answer) {
@@ -32,6 +32,10 @@ function readStartLogin(answer) {
32
32
  ok: false,
33
33
  reason: "invalidEmail"
34
34
  };
35
+ if (data.ok === false && data.reason === "throttled") return {
36
+ ok: false,
37
+ reason: "throttled"
38
+ };
35
39
  }
36
40
  if (envelope?.validationErrors && !envelope.serverError) return {
37
41
  ok: false,
@@ -1 +1 @@
1
- {"version":3,"file":"actions.mjs","names":[],"sources":["../../src/react/actions.ts"],"sourcesContent":["/**\n * The portal's server actions, as the components take them (Decision 8).\n * `@medalsocial/booking/next`'s `booking.portal.actions` satisfy this shape\n * (`tests/react/actions-contract.test.ts`), so the app's wrappers pass them\n * straight through.\n *\n * A library cannot ship a `'use server'` module reliably, so the app owns\n * one three-line wrapper per action and passes the wrappers in as props. The\n * shapes below are what the components call them with and what they read\n * back. An action may answer with the plain result, or with\n * next-safe-action's `{ data, serverError, validationErrors }` envelope\n * around it; `readAction` turns either into the one shape a form acts on.\n */\n\nimport type { VippsLinkStartResult, VippsStartAction } from '@medalsocial/meda/booking';\nimport type { PortalProfileDto } from '../core/portal/dto';\n\n/** The session is gone (or never was): the visitor goes back to the login. */\nexport type SessionFailure = { ok: false; reason: 'session' };\n\n/** The backend refused the input, with a sentence to show. */\nexport type InvalidFailure = { ok: false; reason: 'invalid'; message: string };\n\n/** next-safe-action's envelope. */\nexport interface SafeActionEnvelope<T> {\n data?: T;\n serverError?: string;\n validationErrors?: unknown;\n}\n\n/** What an action may answer with: its result, or the envelope around it. */\nexport type ActionAnswer<T> = T | SafeActionEnvelope<T>;\n\nexport type ProfileActionResult =\n | { ok: true; profile: PortalProfileDto }\n | SessionFailure\n | InvalidFailure;\n\nexport type PersonActionResult =\n | { ok: true; profile: PortalProfileDto; personId: string | null; fallback: boolean }\n | SessionFailure\n | InvalidFailure;\n\n/** A person as an action writes it (the wire's snake_case, as the source's schema). */\nexport interface PersonActionInput {\n name: string;\n birth_year: number;\n birth_month?: number | null;\n notes?: string | null;\n preferred_resource_id?: string | null;\n}\n\n/** Which person: the id, or the family index where there is none. */\nexport interface PersonActionTarget {\n person_id?: string | null;\n index?: number;\n}\n\nexport interface PortalActions {\n /**\n * Ask for a login code. Answers «sent» whether or not the address is known;\n * `invalid` only for an address that cannot be one.\n */\n startLogin(input: { email: string }): Promise<ActionAnswer<{ status: 'sent' } | InvalidFailure>>;\n /** Start a Vipps login: a form action that redirects to Vipps, or answers why not. */\n startVipps?: VippsStartAction;\n /**\n * Start linking Vipps to the logged-in profile — `useActionState`'s shape,\n * like `startVipps`; it is called with `null` and no form data.\n */\n startVippsLink?: (\n previous: VippsLinkStartResult,\n formData?: FormData\n ) => Promise<VippsLinkStartResult>;\n updateProfile(input: {\n first_name?: string;\n last_name?: string;\n phone?: string | null;\n }): Promise<ActionAnswer<ProfileActionResult>>;\n setMarketingConsent(input: {\n granted: boolean;\n }): Promise<\n ActionAnswer<{ ok: true; marketingConsent: boolean } | SessionFailure | InvalidFailure>\n >;\n savePerson(\n input: PersonActionTarget & { person: PersonActionInput; create: boolean }\n ): Promise<ActionAnswer<PersonActionResult>>;\n removePerson(input: PersonActionTarget): Promise<ActionAnswer<PersonActionResult>>;\n logout(): Promise<ActionAnswer<{ ok: true }>>;\n exportData(): Promise<\n ActionAnswer<{ ok: true; filename: string; json: string } | SessionFailure>\n >;\n /** `confirm` is the typed confirmation word; the backend re-checks it. */\n deleteMe(input: {\n confirm: string;\n }): Promise<ActionAnswer<{ ok: true } | SessionFailure | InvalidFailure>>;\n}\n\n/** Why an action did not come back `ok`: a dead session, or the sentence to show. */\nexport type ActionFailure = { kind: 'session' } | { kind: 'error'; message: string };\n\n/** The first message zod attached, wherever next-safe-action nested it. */\nfunction firstValidationMessage(node: unknown): string | null {\n if (!node || typeof node !== 'object') return null;\n const record = node as Record<string, unknown>;\n const own = record._errors;\n if (Array.isArray(own) && typeof own[0] === 'string') return own[0];\n for (const [key, value] of Object.entries(record)) {\n if (key === '_errors') continue;\n const nested = firstValidationMessage(value);\n if (nested) return nested;\n }\n return null;\n}\n\nfunction isEnvelope<T>(answer: ActionAnswer<T> | undefined): answer is SafeActionEnvelope<T> {\n return (\n typeof answer === 'object' &&\n answer !== null &&\n !('ok' in answer) &&\n ('data' in answer || 'serverError' in answer || 'validationErrors' in answer)\n );\n}\n\n/**\n * `startLogin`'s answer, read for the login form: `sent` only for a result\n * that says so — plain or inside the envelope. The action's own refusal of\n * the address (or zod's) is a bad address; a server error, an empty envelope,\n * an answer that is neither, or no answer at all is `unreachable`, so the\n * form never moves on to a code that was not sent.\n */\nexport function readStartLogin(\n answer: ActionAnswer<{ status: 'sent' } | InvalidFailure> | undefined\n): { ok: true } | { ok: false; reason: 'unreachable' | 'invalidEmail' } {\n const envelope = isEnvelope(answer) ? answer : undefined;\n const data = (envelope ? envelope.data : answer) as\n | { status?: unknown; ok?: unknown; reason?: unknown }\n | null\n | undefined;\n if (data && typeof data === 'object') {\n if (data.status === 'sent') return { ok: true };\n if (data.ok === false && data.reason === 'invalid')\n return { ok: false, reason: 'invalidEmail' };\n }\n if (envelope?.validationErrors && !envelope.serverError) {\n return { ok: false, reason: 'invalidEmail' };\n }\n return { ok: false, reason: 'unreachable' };\n}\n\n/**\n * An action's answer, read: `{ ok: true, value }`, or the failure a form acts\n * on. The action's own `message` wins over zod's, which wins over\n * `invalidInput`; anything unreadable (a thrown call, a server error, an\n * answer with nothing in it) is `unreachable`.\n */\nexport function readAction<T extends object>(\n answer: ActionAnswer<T> | undefined,\n words: { unreachable: string; invalidInput: string }\n): { ok: true; value: T } | { ok: false; failure: ActionFailure } {\n const envelope = isEnvelope(answer) ? answer : undefined;\n const data = envelope ? envelope.data : (answer as T | undefined);\n if (data && typeof data === 'object') {\n const result = data as { ok?: unknown; reason?: unknown; message?: unknown };\n if (result.ok !== false) return { ok: true, value: data };\n if (result.reason === 'session') return { ok: false, failure: { kind: 'session' } };\n if (typeof result.message === 'string') {\n return { ok: false, failure: { kind: 'error', message: result.message } };\n }\n }\n if (envelope?.validationErrors) {\n const message = firstValidationMessage(envelope.validationErrors) ?? words.invalidInput;\n return { ok: false, failure: { kind: 'error', message } };\n }\n return { ok: false, failure: { kind: 'error', message: words.unreachable } };\n}\n"],"mappings":";;;AAsGA,SAAS,uBAAuB,MAA8B;CAC5D,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU,OAAO;CAC9C,MAAM,SAAS;CACf,MAAM,MAAM,OAAO;CACnB,IAAI,MAAM,QAAQ,GAAG,KAAK,OAAO,IAAI,OAAO,UAAU,OAAO,IAAI;CACjE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG;EACjD,IAAI,QAAQ,WAAW;EACvB,MAAM,SAAS,uBAAuB,KAAK;EAC3C,IAAI,QAAQ,OAAO;CACrB;CACA,OAAO;AACT;AAEA,SAAS,WAAc,QAAsE;CAC3F,OACE,OAAO,WAAW,YAClB,WAAW,QACX,EAAE,QAAQ,YACT,UAAU,UAAU,iBAAiB,UAAU,sBAAsB;AAE1E;;;;;;;;AASA,SAAgB,eACd,QACsE;CACtE,MAAM,WAAW,WAAW,MAAM,IAAI,SAAS,KAAA;CAC/C,MAAM,OAAQ,WAAW,SAAS,OAAO;CAIzC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,IAAI,KAAK,WAAW,QAAQ,OAAO,EAAE,IAAI,KAAK;EAC9C,IAAI,KAAK,OAAO,SAAS,KAAK,WAAW,WACvC,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAe;CAC/C;CACA,IAAI,UAAU,oBAAoB,CAAC,SAAS,aAC1C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAe;CAE7C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAc;AAC5C;;;;;;;AAQA,SAAgB,WACd,QACA,OACgE;CAChE,MAAM,WAAW,WAAW,MAAM,IAAI,SAAS,KAAA;CAC/C,MAAM,OAAO,WAAW,SAAS,OAAQ;CACzC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,MAAM,SAAS;EACf,IAAI,OAAO,OAAO,OAAO,OAAO;GAAE,IAAI;GAAM,OAAO;EAAK;EACxD,IAAI,OAAO,WAAW,WAAW,OAAO;GAAE,IAAI;GAAO,SAAS,EAAE,MAAM,UAAU;EAAE;EAClF,IAAI,OAAO,OAAO,YAAY,UAC5B,OAAO;GAAE,IAAI;GAAO,SAAS;IAAE,MAAM;IAAS,SAAS,OAAO;GAAQ;EAAE;CAE5E;CACA,IAAI,UAAU,kBAEZ,OAAO;EAAE,IAAI;EAAO,SAAS;GAAE,MAAM;GAAS,SAD9B,uBAAuB,SAAS,gBAAgB,KAAK,MAAM;EACrB;CAAE;CAE1D,OAAO;EAAE,IAAI;EAAO,SAAS;GAAE,MAAM;GAAS,SAAS,MAAM;EAAY;CAAE;AAC7E"}
1
+ {"version":3,"file":"actions.mjs","names":[],"sources":["../../src/react/actions.ts"],"sourcesContent":["/**\n * The portal's server actions, as the components take them (Decision 8).\n * `@medalsocial/booking/next`'s `booking.portal.actions` satisfy this shape\n * (`tests/react/actions-contract.test.ts`), so the app's wrappers pass them\n * straight through.\n *\n * A library cannot ship a `'use server'` module reliably, so the app owns\n * one three-line wrapper per action and passes the wrappers in as props. The\n * shapes below are what the components call them with and what they read\n * back. An action may answer with the plain result, or with\n * next-safe-action's `{ data, serverError, validationErrors }` envelope\n * around it; `readAction` turns either into the one shape a form acts on.\n */\n\nimport type { VippsLinkStartResult, VippsStartAction } from '@medalsocial/meda/booking';\nimport type { PortalProfileDto } from '../core/portal/dto';\n\n/** The session is gone (or never was): the visitor goes back to the login. */\nexport type SessionFailure = { ok: false; reason: 'session' };\n\n/** The backend refused the input, with a sentence to show. */\nexport type InvalidFailure = { ok: false; reason: 'invalid'; message: string };\n\n/**\n * The app itself is limiting how often a code may be asked for (the site's\n * per-IP limiter). Never the package's own answer for MEDAL's per-address\n * throttle: `next/portal` still answers «sent» for that one, because telling\n * the two apart would let anyone confirm which parents are customers.\n */\nexport type ThrottledFailure = { ok: false; reason: 'throttled' };\n\n/** next-safe-action's envelope. */\nexport interface SafeActionEnvelope<T> {\n data?: T;\n serverError?: string;\n validationErrors?: unknown;\n}\n\n/** What an action may answer with: its result, or the envelope around it. */\nexport type ActionAnswer<T> = T | SafeActionEnvelope<T>;\n\nexport type ProfileActionResult =\n | { ok: true; profile: PortalProfileDto }\n | SessionFailure\n | InvalidFailure;\n\nexport type PersonActionResult =\n | { ok: true; profile: PortalProfileDto; personId: string | null; fallback: boolean }\n | SessionFailure\n | InvalidFailure;\n\n/** A person as an action writes it (the wire's snake_case, as the source's schema). */\nexport interface PersonActionInput {\n name: string;\n birth_year: number;\n birth_month?: number | null;\n notes?: string | null;\n preferred_resource_id?: string | null;\n}\n\n/** Which person: the id, or the family index where there is none. */\nexport interface PersonActionTarget {\n person_id?: string | null;\n index?: number;\n}\n\nexport interface PortalActions {\n /**\n * Ask for a login code. Answers «sent» whether or not the address is known;\n * `invalid` only for an address that cannot be one, `throttled` only when\n * the app's own limiter refused.\n */\n startLogin(input: {\n email: string;\n }): Promise<ActionAnswer<{ status: 'sent' } | InvalidFailure | ThrottledFailure>>;\n /** Start a Vipps login: a form action that redirects to Vipps, or answers why not. */\n startVipps?: VippsStartAction;\n /**\n * Start linking Vipps to the logged-in profile — `useActionState`'s shape,\n * like `startVipps`; it is called with `null` and no form data.\n */\n startVippsLink?: (\n previous: VippsLinkStartResult,\n formData?: FormData\n ) => Promise<VippsLinkStartResult>;\n updateProfile(input: {\n first_name?: string;\n last_name?: string;\n phone?: string | null;\n }): Promise<ActionAnswer<ProfileActionResult>>;\n setMarketingConsent(input: {\n granted: boolean;\n }): Promise<\n ActionAnswer<{ ok: true; marketingConsent: boolean } | SessionFailure | InvalidFailure>\n >;\n savePerson(\n input: PersonActionTarget & { person: PersonActionInput; create: boolean }\n ): Promise<ActionAnswer<PersonActionResult>>;\n removePerson(input: PersonActionTarget): Promise<ActionAnswer<PersonActionResult>>;\n logout(): Promise<ActionAnswer<{ ok: true }>>;\n exportData(): Promise<\n ActionAnswer<{ ok: true; filename: string; json: string } | SessionFailure>\n >;\n /** `confirm` is the typed confirmation word; the backend re-checks it. */\n deleteMe(input: {\n confirm: string;\n }): Promise<ActionAnswer<{ ok: true } | SessionFailure | InvalidFailure>>;\n}\n\n/** Why an action did not come back `ok`: a dead session, or the sentence to show. */\nexport type ActionFailure = { kind: 'session' } | { kind: 'error'; message: string };\n\n/** The first message zod attached, wherever next-safe-action nested it. */\nfunction firstValidationMessage(node: unknown): string | null {\n if (!node || typeof node !== 'object') return null;\n const record = node as Record<string, unknown>;\n const own = record._errors;\n if (Array.isArray(own) && typeof own[0] === 'string') return own[0];\n for (const [key, value] of Object.entries(record)) {\n if (key === '_errors') continue;\n const nested = firstValidationMessage(value);\n if (nested) return nested;\n }\n return null;\n}\n\nfunction isEnvelope<T>(answer: ActionAnswer<T> | undefined): answer is SafeActionEnvelope<T> {\n return (\n typeof answer === 'object' &&\n answer !== null &&\n !('ok' in answer) &&\n ('data' in answer || 'serverError' in answer || 'validationErrors' in answer)\n );\n}\n\n/**\n * `startLogin`'s answer, read for the login form: `sent` only for a result\n * that says so — plain or inside the envelope. The action's own refusal of\n * the address (or zod's) is a bad address; a server error, an empty envelope,\n * an app-level `throttled` stays `throttled`; an answer that is neither, or no answer at all is `unreachable`, so the\n * form never moves on to a code that was not sent.\n */\nexport function readStartLogin(\n answer: ActionAnswer<{ status: 'sent' } | InvalidFailure | ThrottledFailure> | undefined\n): { ok: true } | { ok: false; reason: 'unreachable' | 'invalidEmail' | 'throttled' } {\n const envelope = isEnvelope(answer) ? answer : undefined;\n const data = (envelope ? envelope.data : answer) as\n | { status?: unknown; ok?: unknown; reason?: unknown }\n | null\n | undefined;\n if (data && typeof data === 'object') {\n if (data.status === 'sent') return { ok: true };\n if (data.ok === false && data.reason === 'invalid')\n return { ok: false, reason: 'invalidEmail' };\n if (data.ok === false && data.reason === 'throttled') return { ok: false, reason: 'throttled' };\n }\n if (envelope?.validationErrors && !envelope.serverError) {\n return { ok: false, reason: 'invalidEmail' };\n }\n return { ok: false, reason: 'unreachable' };\n}\n\n/**\n * An action's answer, read: `{ ok: true, value }`, or the failure a form acts\n * on. The action's own `message` wins over zod's, which wins over\n * `invalidInput`; anything unreadable (a thrown call, a server error, an\n * answer with nothing in it) is `unreachable`.\n */\nexport function readAction<T extends object>(\n answer: ActionAnswer<T> | undefined,\n words: { unreachable: string; invalidInput: string }\n): { ok: true; value: T } | { ok: false; failure: ActionFailure } {\n const envelope = isEnvelope(answer) ? answer : undefined;\n const data = envelope ? envelope.data : (answer as T | undefined);\n if (data && typeof data === 'object') {\n const result = data as { ok?: unknown; reason?: unknown; message?: unknown };\n if (result.ok !== false) return { ok: true, value: data };\n if (result.reason === 'session') return { ok: false, failure: { kind: 'session' } };\n if (typeof result.message === 'string') {\n return { ok: false, failure: { kind: 'error', message: result.message } };\n }\n }\n if (envelope?.validationErrors) {\n const message = firstValidationMessage(envelope.validationErrors) ?? words.invalidInput;\n return { ok: false, failure: { kind: 'error', message } };\n }\n return { ok: false, failure: { kind: 'error', message: words.unreachable } };\n}\n"],"mappings":";;;AAiHA,SAAS,uBAAuB,MAA8B;CAC5D,IAAI,CAAC,QAAQ,OAAO,SAAS,UAAU,OAAO;CAC9C,MAAM,SAAS;CACf,MAAM,MAAM,OAAO;CACnB,IAAI,MAAM,QAAQ,GAAG,KAAK,OAAO,IAAI,OAAO,UAAU,OAAO,IAAI;CACjE,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,GAAG;EACjD,IAAI,QAAQ,WAAW;EACvB,MAAM,SAAS,uBAAuB,KAAK;EAC3C,IAAI,QAAQ,OAAO;CACrB;CACA,OAAO;AACT;AAEA,SAAS,WAAc,QAAsE;CAC3F,OACE,OAAO,WAAW,YAClB,WAAW,QACX,EAAE,QAAQ,YACT,UAAU,UAAU,iBAAiB,UAAU,sBAAsB;AAE1E;;;;;;;;AASA,SAAgB,eACd,QACoF;CACpF,MAAM,WAAW,WAAW,MAAM,IAAI,SAAS,KAAA;CAC/C,MAAM,OAAQ,WAAW,SAAS,OAAO;CAIzC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,IAAI,KAAK,WAAW,QAAQ,OAAO,EAAE,IAAI,KAAK;EAC9C,IAAI,KAAK,OAAO,SAAS,KAAK,WAAW,WACvC,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAe;EAC7C,IAAI,KAAK,OAAO,SAAS,KAAK,WAAW,aAAa,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAY;CAChG;CACA,IAAI,UAAU,oBAAoB,CAAC,SAAS,aAC1C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAe;CAE7C,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAc;AAC5C;;;;;;;AAQA,SAAgB,WACd,QACA,OACgE;CAChE,MAAM,WAAW,WAAW,MAAM,IAAI,SAAS,KAAA;CAC/C,MAAM,OAAO,WAAW,SAAS,OAAQ;CACzC,IAAI,QAAQ,OAAO,SAAS,UAAU;EACpC,MAAM,SAAS;EACf,IAAI,OAAO,OAAO,OAAO,OAAO;GAAE,IAAI;GAAM,OAAO;EAAK;EACxD,IAAI,OAAO,WAAW,WAAW,OAAO;GAAE,IAAI;GAAO,SAAS,EAAE,MAAM,UAAU;EAAE;EAClF,IAAI,OAAO,OAAO,YAAY,UAC5B,OAAO;GAAE,IAAI;GAAO,SAAS;IAAE,MAAM;IAAS,SAAS,OAAO;GAAQ;EAAE;CAE5E;CACA,IAAI,UAAU,kBAEZ,OAAO;EAAE,IAAI;EAAO,SAAS;GAAE,MAAM;GAAS,SAD9B,uBAAuB,SAAS,gBAAgB,KAAK,MAAM;EACrB;CAAE;CAE1D,OAAO;EAAE,IAAI;EAAO,SAAS;GAAE,MAAM;GAAS,SAAS,MAAM;EAAY;CAAE;AAC7E"}
@@ -1,5 +1,5 @@
1
1
  import { NextFree } from "../core/next-free.mjs";
2
- import { ActionAnswer, ActionFailure, InvalidFailure, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, ProfileActionResult, SafeActionEnvelope, SessionFailure, readAction, readStartLogin } from "./actions.mjs";
2
+ import { ActionAnswer, ActionFailure, InvalidFailure, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, ProfileActionResult, SafeActionEnvelope, SessionFailure, ThrottledFailure, readAction, readStartLogin } from "./actions.mjs";
3
3
  import { LoginLabels } from "./labels/login.mjs";
4
4
  import { ManageLabels } from "./labels/manage.mjs";
5
5
  import { PortalLabels } from "./labels/portal.mjs";
@@ -25,4 +25,4 @@ import { SESSION_TOUCH_THROTTLE_MS, SessionTouch, sessionTouchKey } from "./port
25
25
  import { VIPPS_LINK_FLASHES, VippsLinkFlash, vippsFlashPath, vippsLinkFlash } from "./portal/vipps-flash.mjs";
26
26
  import "./PortalDashboard.mjs";
27
27
  import { nextFreeClient, useNextFree } from "./use-next-free.mjs";
28
- export { ActionAnswer, ActionFailure, BOOKING_NAVIGATION_EVENT, BookingClassNames, BookingComponents, BookingConfirmation, BookingController, BookingKit, type BookingLabels, type BookingLabelsInput, BookingLink, BookingLinkProps, BookingOverrides, BookingPendingHost, BookingProvider, BookingProviderProps, BookingSeed, BookingSuggestion, BookingWizard, BookingWizardEvent, BookingWizardProps, ConfirmedLine, DEFAULT_PORTAL_TAB, DEFAULT_PORTAL_TAB_PARAM, DEFAULT_PORTAL_TAB_SLUGS, InvalidFailure, LoginFromQuery, LoginFromQuery as LoginPageQuery, LoginFromQueryProps, type LoginLabels, LoginSheet, LoginSheetProps, ManageBooking, ManageBookingProps, type ManageLabels, type NextFree, PENDING_DELAY_MS, PENDING_TIMEOUT_MS, PORTAL_TABS, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, PortalDashboard, type PortalDashboardProps, PortalDashboardUnreachable, type PortalDashboardUnreachableProps, type PortalLabels, type PortalTab, type PortalTabIcons, ProfileActionResult, ResolvedBooking, SESSION_TOUCH_THROTTLE_MS, SafeActionEnvelope, type ScreenLabels, SessionFailure, SessionTouch, UseBookingOptions, VIPPS_LINK_FLASHES, type VippsConfirm, type VippsLinkFlash, VippsNotice, type WizardLabels, agePromptCookie, agePromptCookieName, bookAgainHref, bookingPrefetch, bookingTarget, confirmationLines, createBookingKit, createChildSummaries, isCompleteConfirmation, leavePortal, nextFreeClient, parseAgePromptDismissed, parsePortalTab, personForChild, readAction, readStartLogin, returnPathFromSearch, sessionTouchKey, useBooking, useBookingKit, useNextFree, vippsFlashPath, vippsLinkFlash, vippsNotice };
28
+ export { ActionAnswer, ActionFailure, BOOKING_NAVIGATION_EVENT, BookingClassNames, BookingComponents, BookingConfirmation, BookingController, BookingKit, type BookingLabels, type BookingLabelsInput, BookingLink, BookingLinkProps, BookingOverrides, BookingPendingHost, BookingProvider, BookingProviderProps, BookingSeed, BookingSuggestion, BookingWizard, BookingWizardEvent, BookingWizardProps, ConfirmedLine, DEFAULT_PORTAL_TAB, DEFAULT_PORTAL_TAB_PARAM, DEFAULT_PORTAL_TAB_SLUGS, InvalidFailure, LoginFromQuery, LoginFromQuery as LoginPageQuery, LoginFromQueryProps, type LoginLabels, LoginSheet, LoginSheetProps, ManageBooking, ManageBookingProps, type ManageLabels, type NextFree, PENDING_DELAY_MS, PENDING_TIMEOUT_MS, PORTAL_TABS, PersonActionInput, PersonActionResult, PersonActionTarget, PortalActions, PortalDashboard, type PortalDashboardProps, PortalDashboardUnreachable, type PortalDashboardUnreachableProps, type PortalLabels, type PortalTab, type PortalTabIcons, ProfileActionResult, ResolvedBooking, SESSION_TOUCH_THROTTLE_MS, SafeActionEnvelope, type ScreenLabels, SessionFailure, SessionTouch, ThrottledFailure, UseBookingOptions, VIPPS_LINK_FLASHES, type VippsConfirm, type VippsLinkFlash, VippsNotice, type WizardLabels, agePromptCookie, agePromptCookieName, bookAgainHref, bookingPrefetch, bookingTarget, confirmationLines, createBookingKit, createChildSummaries, isCompleteConfirmation, leavePortal, nextFreeClient, parseAgePromptDismissed, parsePortalTab, personForChild, readAction, readStartLogin, returnPathFromSearch, sessionTouchKey, useBooking, useBookingKit, useNextFree, vippsFlashPath, vippsLinkFlash, vippsNotice };
@@ -35,10 +35,15 @@ export interface BookingKit {
35
35
  readonly paths: Paths;
36
36
  /** The day parts in order, as the time step takes them. */
37
37
  readonly dayparts: readonly BookingDaypart[];
38
- /** The service groups in order, as the service step takes them. */
38
+ /**
39
+ * The service groups in order, as the service step takes them — with each
40
+ * group's audience, so a child in a family is offered the groups meant for
41
+ * anyone (meda 3.6 `ServiceScreenCategory.audience`).
42
+ */
39
43
  readonly categories: ReadonlyArray<{
40
44
  key: string;
41
45
  label: string;
46
+ audience: 'child' | 'adult' | 'any';
42
47
  }>;
43
48
  /** The kids' group, or `null` when the site has none. */
44
49
  readonly childCategory: string | null;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { categoryOrder, childCategory } from "../core/categories.mjs";
2
+ import { childCategory } from "../core/categories.mjs";
3
3
  import { labelText } from "../core/labels.mjs";
4
4
  import { createAge } from "../core/age.mjs";
5
5
  import { createAttemptStore } from "../core/attempt-store.mjs";
@@ -110,11 +110,12 @@ function buildKit(base, labels) {
110
110
  key: part.key,
111
111
  label: core.daypartLabel(part.key)
112
112
  })),
113
- categories: categoryOrder(config).map((key) => {
113
+ categories: config.categories.map(({ key, audience }) => {
114
114
  const label = labels[`category.${key}`];
115
115
  return {
116
116
  key,
117
- label: label === void 0 ? capitalised(key, config.locale) : labelText(label)
117
+ label: label === void 0 ? capitalised(key, config.locale) : labelText(label),
118
+ audience
118
119
  };
119
120
  }),
120
121
  childCategory: childCategory(config),
@@ -1 +1 @@
1
- {"version":3,"file":"kit.mjs","names":[],"sources":["../../src/react/kit.ts"],"sourcesContent":["/**\n * Everything a booking screen needs that depends on the site, built once per\n * config and label pack: the core factories (machine, clock, money, phone, …)\n * and the `format` object every meda screen takes.\n *\n * One kit per `(config, labels)` pair, memoised, so the components that share\n * a `BookingProvider` share one clock (whose formatters are the expensive\n * part) and one wizard.\n */\n\nimport type { BookingClock, BookingDaypart, BookingFormat } from '@medalsocial/meda/booking';\nimport { type Age, createAge } from '../core/age';\nimport { type AttemptStore, createAttemptStore } from '../core/attempt-store';\nimport { categoryOrder, childCategory } from '../core/categories';\nimport { type Clock, createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { createDeepLinks, type DeepLinks } from '../core/deep-link';\nimport { initialsOf, stylistDisplayName } from '../core/display-name';\nimport { createDraftStore, type DraftStore } from '../core/draft-store';\nimport { createIcs, type Ics } from '../core/ics';\nimport { labelText } from '../core/labels';\nimport { createWizard, type Wizard } from '../core/machine';\nimport { createMoney, type Money } from '../core/money';\nimport { createPartySlots, type PartySlots } from '../core/party-slots';\nimport { createPaths, type Paths } from '../core/paths';\nimport { createPhone, type Phone } from '../core/phone';\nimport { createRebookStore, type RebookStore } from '../core/rebook-store';\nimport { createRestoreGate, type RestoreGate } from '../core/restore-gate';\nimport type { BookingLabels } from './labels';\n\nexport interface BookingKit {\n /** The config with the merged label pack folded in, so the core reads the same words. */\n readonly config: Readonly<BookingConfig>;\n readonly labels: Readonly<BookingLabels>;\n readonly format: BookingFormat;\n readonly clock: Clock & BookingClock;\n readonly wizard: Wizard;\n readonly money: Money;\n readonly phone: Phone;\n readonly age: Age;\n readonly deepLinks: DeepLinks;\n readonly drafts: DraftStore;\n readonly attempts: AttemptStore;\n readonly rebook: RebookStore;\n readonly restoreGate: RestoreGate;\n readonly partySlots: PartySlots;\n readonly ics: Ics;\n readonly paths: Paths;\n /** The day parts in order, as the time step takes them. */\n readonly dayparts: readonly BookingDaypart[];\n /** The service groups in order, as the service step takes them. */\n readonly categories: ReadonlyArray<{ key: string; label: string }>;\n /** The kids' group, or `null` when the site has none. */\n readonly childCategory: string | null;\n /** «Theos» / «Jonas'» — the language's possessive of a name. */\n possessive(name: string): string;\n}\n\n/** «barn» → «Barn», for a group the label pack has no word for. */\nfunction capitalised(key: string, locale: string): string {\n return `${key.charAt(0).toLocaleUpperCase(locale)}${key.slice(1)}`;\n}\n\nfunction isNorwegian(locale: string): boolean {\n const language = locale.toLowerCase().split('-')[0];\n return language === 'nb' || language === 'no' || language === 'nn';\n}\n\n/**\n * The possessive the age divider puts a child's name in. Norwegian adds `s`,\n * or an apostrophe after a name that already ends in s, x or z («Theos»,\n * «Jonas'»); English adds `'s`, or the apostrophe alone after an s.\n */\nfunction possessiveFor(locale: string): (name: string) => string {\n if (isNorwegian(locale)) return (name) => (/[sxz]$/i.test(name) ? `${name}'` : `${name}s`);\n return (name) => (/s$/i.test(name) ? `${name}'` : `${name}'s`);\n}\n\n/** The two things meda's clock asks for that the core clock does not draw. */\nfunction calendarWords(config: Pick<BookingConfig, 'locale' | 'timeZone'>) {\n const month = new Intl.DateTimeFormat(config.locale, { month: 'long', timeZone: 'UTC' });\n const weekday = new Intl.DateTimeFormat(config.locale, { weekday: 'short', timeZone: 'UTC' });\n const months = Array.from({ length: 12 }, (_, index) => month.format(Date.UTC(2026, index, 15)));\n // 5 January 2026 is a Monday: seven days from it are the heads, Monday first.\n const heads = Array.from({ length: 7 }, (_, index) => {\n const short = weekday.format(Date.UTC(2026, 0, 5 + index)).replace(/\\.$/, '');\n return capitalised(short.slice(0, 2), config.locale);\n });\n return {\n monthName: (value: number) => months[(value - 1 + 12) % 12],\n weekdayHeads: () => heads,\n };\n}\n\nconst KITS = new WeakMap<object, WeakMap<object, BookingKit>>();\n\n/**\n * The kit for one config and one merged label pack. Pass the SAME objects to\n * get the same kit back; `BookingProvider` and the components memoise them.\n */\nexport function createBookingKit(\n config: Readonly<BookingConfig>,\n labels: Readonly<BookingLabels>\n): BookingKit {\n let byLabels = KITS.get(config);\n if (byLabels === undefined) {\n byLabels = new WeakMap();\n KITS.set(config, byLabels);\n }\n const known = byLabels.get(labels);\n if (known !== undefined) return known;\n const kit = buildKit(config, labels);\n byLabels.set(labels, kit);\n return kit;\n}\n\nfunction buildKit(base: Readonly<BookingConfig>, labels: Readonly<BookingLabels>): BookingKit {\n const config: Readonly<BookingConfig> = Object.freeze({ ...base, labels });\n const core = createClock(config);\n const clock: Clock & BookingClock = { ...core, ...calendarWords(config) };\n const money = createMoney(config);\n const phone = createPhone(config);\n const age = createAge(config);\n const format: BookingFormat = {\n clock,\n price: (minor) => money.formatMinor(minor),\n telHref: (value) => phone.telHref(value),\n stylistName: stylistDisplayName,\n initials: initialsOf,\n ageLabel: (range) => age.ageLabel(range),\n };\n return {\n config,\n labels,\n format,\n clock,\n wizard: createWizard(config),\n money,\n phone,\n age,\n deepLinks: createDeepLinks(config),\n drafts: createDraftStore(config.storageNamespace),\n attempts: createAttemptStore(config.storageNamespace),\n rebook: createRebookStore(config.storageNamespace),\n restoreGate: createRestoreGate(config.storageNamespace),\n partySlots: createPartySlots(config),\n ics: createIcs(config),\n paths: createPaths(config),\n dayparts: config.dayparts.map((part) => ({\n key: part.key,\n label: core.daypartLabel(part.key),\n })),\n categories: categoryOrder(config).map((key) => {\n const label = labels[`category.${key}`];\n return {\n key,\n label: label === undefined ? capitalised(key, config.locale) : labelText(label),\n };\n }),\n childCategory: childCategory(config),\n possessive: possessiveFor(config.locale),\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"kit.mjs","names":[],"sources":["../../src/react/kit.ts"],"sourcesContent":["/**\n * Everything a booking screen needs that depends on the site, built once per\n * config and label pack: the core factories (machine, clock, money, phone, …)\n * and the `format` object every meda screen takes.\n *\n * One kit per `(config, labels)` pair, memoised, so the components that share\n * a `BookingProvider` share one clock (whose formatters are the expensive\n * part) and one wizard.\n */\n\nimport type { BookingClock, BookingDaypart, BookingFormat } from '@medalsocial/meda/booking';\nimport { type Age, createAge } from '../core/age';\nimport { type AttemptStore, createAttemptStore } from '../core/attempt-store';\nimport { childCategory } from '../core/categories';\nimport { type Clock, createClock } from '../core/clock';\nimport type { BookingConfig } from '../core/config';\nimport { createDeepLinks, type DeepLinks } from '../core/deep-link';\nimport { initialsOf, stylistDisplayName } from '../core/display-name';\nimport { createDraftStore, type DraftStore } from '../core/draft-store';\nimport { createIcs, type Ics } from '../core/ics';\nimport { labelText } from '../core/labels';\nimport { createWizard, type Wizard } from '../core/machine';\nimport { createMoney, type Money } from '../core/money';\nimport { createPartySlots, type PartySlots } from '../core/party-slots';\nimport { createPaths, type Paths } from '../core/paths';\nimport { createPhone, type Phone } from '../core/phone';\nimport { createRebookStore, type RebookStore } from '../core/rebook-store';\nimport { createRestoreGate, type RestoreGate } from '../core/restore-gate';\nimport type { BookingLabels } from './labels';\n\nexport interface BookingKit {\n /** The config with the merged label pack folded in, so the core reads the same words. */\n readonly config: Readonly<BookingConfig>;\n readonly labels: Readonly<BookingLabels>;\n readonly format: BookingFormat;\n readonly clock: Clock & BookingClock;\n readonly wizard: Wizard;\n readonly money: Money;\n readonly phone: Phone;\n readonly age: Age;\n readonly deepLinks: DeepLinks;\n readonly drafts: DraftStore;\n readonly attempts: AttemptStore;\n readonly rebook: RebookStore;\n readonly restoreGate: RestoreGate;\n readonly partySlots: PartySlots;\n readonly ics: Ics;\n readonly paths: Paths;\n /** The day parts in order, as the time step takes them. */\n readonly dayparts: readonly BookingDaypart[];\n /**\n * The service groups in order, as the service step takes them — with each\n * group's audience, so a child in a family is offered the groups meant for\n * anyone (meda 3.6 `ServiceScreenCategory.audience`).\n */\n readonly categories: ReadonlyArray<{\n key: string;\n label: string;\n audience: 'child' | 'adult' | 'any';\n }>;\n /** The kids' group, or `null` when the site has none. */\n readonly childCategory: string | null;\n /** «Theos» / «Jonas'» — the language's possessive of a name. */\n possessive(name: string): string;\n}\n\n/** «barn» → «Barn», for a group the label pack has no word for. */\nfunction capitalised(key: string, locale: string): string {\n return `${key.charAt(0).toLocaleUpperCase(locale)}${key.slice(1)}`;\n}\n\nfunction isNorwegian(locale: string): boolean {\n const language = locale.toLowerCase().split('-')[0];\n return language === 'nb' || language === 'no' || language === 'nn';\n}\n\n/**\n * The possessive the age divider puts a child's name in. Norwegian adds `s`,\n * or an apostrophe after a name that already ends in s, x or z («Theos»,\n * «Jonas'»); English adds `'s`, or the apostrophe alone after an s.\n */\nfunction possessiveFor(locale: string): (name: string) => string {\n if (isNorwegian(locale)) return (name) => (/[sxz]$/i.test(name) ? `${name}'` : `${name}s`);\n return (name) => (/s$/i.test(name) ? `${name}'` : `${name}'s`);\n}\n\n/** The two things meda's clock asks for that the core clock does not draw. */\nfunction calendarWords(config: Pick<BookingConfig, 'locale' | 'timeZone'>) {\n const month = new Intl.DateTimeFormat(config.locale, { month: 'long', timeZone: 'UTC' });\n const weekday = new Intl.DateTimeFormat(config.locale, { weekday: 'short', timeZone: 'UTC' });\n const months = Array.from({ length: 12 }, (_, index) => month.format(Date.UTC(2026, index, 15)));\n // 5 January 2026 is a Monday: seven days from it are the heads, Monday first.\n const heads = Array.from({ length: 7 }, (_, index) => {\n const short = weekday.format(Date.UTC(2026, 0, 5 + index)).replace(/\\.$/, '');\n return capitalised(short.slice(0, 2), config.locale);\n });\n return {\n monthName: (value: number) => months[(value - 1 + 12) % 12],\n weekdayHeads: () => heads,\n };\n}\n\nconst KITS = new WeakMap<object, WeakMap<object, BookingKit>>();\n\n/**\n * The kit for one config and one merged label pack. Pass the SAME objects to\n * get the same kit back; `BookingProvider` and the components memoise them.\n */\nexport function createBookingKit(\n config: Readonly<BookingConfig>,\n labels: Readonly<BookingLabels>\n): BookingKit {\n let byLabels = KITS.get(config);\n if (byLabels === undefined) {\n byLabels = new WeakMap();\n KITS.set(config, byLabels);\n }\n const known = byLabels.get(labels);\n if (known !== undefined) return known;\n const kit = buildKit(config, labels);\n byLabels.set(labels, kit);\n return kit;\n}\n\nfunction buildKit(base: Readonly<BookingConfig>, labels: Readonly<BookingLabels>): BookingKit {\n const config: Readonly<BookingConfig> = Object.freeze({ ...base, labels });\n const core = createClock(config);\n const clock: Clock & BookingClock = { ...core, ...calendarWords(config) };\n const money = createMoney(config);\n const phone = createPhone(config);\n const age = createAge(config);\n const format: BookingFormat = {\n clock,\n price: (minor) => money.formatMinor(minor),\n telHref: (value) => phone.telHref(value),\n stylistName: stylistDisplayName,\n initials: initialsOf,\n ageLabel: (range) => age.ageLabel(range),\n };\n return {\n config,\n labels,\n format,\n clock,\n wizard: createWizard(config),\n money,\n phone,\n age,\n deepLinks: createDeepLinks(config),\n drafts: createDraftStore(config.storageNamespace),\n attempts: createAttemptStore(config.storageNamespace),\n rebook: createRebookStore(config.storageNamespace),\n restoreGate: createRestoreGate(config.storageNamespace),\n partySlots: createPartySlots(config),\n ics: createIcs(config),\n paths: createPaths(config),\n dayparts: config.dayparts.map((part) => ({\n key: part.key,\n label: core.daypartLabel(part.key),\n })),\n categories: config.categories.map(({ key, audience }) => {\n const label = labels[`category.${key}`];\n return {\n key,\n label: label === undefined ? capitalised(key, config.locale) : labelText(label),\n audience,\n };\n }),\n childCategory: childCategory(config),\n possessive: possessiveFor(config.locale),\n };\n}\n"],"mappings":"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"}
@@ -5,6 +5,8 @@ export type LoginLabels = {
5
5
  'loginPage.vipps.needsEmailLogin': BookingLabel;
6
6
  /** `?vipps=failed`: the Vipps login was cancelled or failed. */
7
7
  'loginPage.vipps.failed': BookingLabel;
8
+ /** The Vipps button where e-mail is the second button: «Fortsett med Vipps». */
9
+ 'login.continueVipps': BookingLabel;
8
10
  };
9
11
  //#endregion
10
12
  //# sourceMappingURL=login.d.mts.map
@@ -1,11 +1,13 @@
1
1
  //#region src/react/labels/login.ts
2
2
  const LOGIN_LABELS_NB = {
3
3
  "loginPage.vipps.needsEmailLogin": "Vi fant mer enn én profil på deg. Logg inn med e-post denne gangen, så kan du koble til Vipps etterpå.",
4
- "loginPage.vipps.failed": "Innloggingen med Vipps ble avbrutt. Prøv igjen, eller bruk e-post."
4
+ "loginPage.vipps.failed": "Innloggingen med Vipps ble avbrutt. Prøv igjen, eller bruk e-post.",
5
+ "login.continueVipps": "Fortsett med Vipps"
5
6
  };
6
7
  const LOGIN_LABELS_EN = {
7
8
  "loginPage.vipps.needsEmailLogin": "We found more than one profile for you. Log in with e-mail this time, and link Vipps afterwards.",
8
- "loginPage.vipps.failed": "The Vipps login was cancelled. Try again, or use e-mail."
9
+ "loginPage.vipps.failed": "The Vipps login was cancelled. Try again, or use e-mail.",
10
+ "login.continueVipps": "Continue with Vipps"
9
11
  };
10
12
  //#endregion
11
13
  export { LOGIN_LABELS_EN, LOGIN_LABELS_NB };
@@ -1 +1 @@
1
- {"version":3,"file":"login.mjs","names":[],"sources":["../../../src/react/labels/login.ts"],"sourcesContent":["/**\n * The words `<LoginSheet>` and `<LoginPageQuery>` draw or assemble\n * themselves, on top of the screens' own copy (`screens.ts`): the sentence the\n * login page shows for each outcome a Vipps return can report (`?vipps=`).\n */\n\nimport type { BookingLabel } from '../../core/labels';\n\nexport type LoginLabels = {\n /** `?vipps=needs_email_login`: the Vipps login matched more than one profile. */\n 'loginPage.vipps.needsEmailLogin': BookingLabel;\n /** `?vipps=failed`: the Vipps login was cancelled or failed. */\n 'loginPage.vipps.failed': BookingLabel;\n};\n\nexport const LOGIN_LABELS_NB: LoginLabels = {\n 'loginPage.vipps.needsEmailLogin':\n 'Vi fant mer enn én profil på deg. Logg inn med e-post denne gangen, så kan du koble til Vipps etterpå.',\n 'loginPage.vipps.failed': 'Innloggingen med Vipps ble avbrutt. Prøv igjen, eller bruk e-post.',\n};\n\nexport const LOGIN_LABELS_EN: LoginLabels = {\n 'loginPage.vipps.needsEmailLogin':\n 'We found more than one profile for you. Log in with e-mail this time, and link Vipps afterwards.',\n 'loginPage.vipps.failed': 'The Vipps login was cancelled. Try again, or use e-mail.',\n};\n"],"mappings":";AAeA,MAAa,kBAA+B;CAC1C,mCACE;CACF,0BAA0B;AAC5B;AAEA,MAAa,kBAA+B;CAC1C,mCACE;CACF,0BAA0B;AAC5B"}
1
+ {"version":3,"file":"login.mjs","names":[],"sources":["../../../src/react/labels/login.ts"],"sourcesContent":["/**\n * The words `<LoginSheet>` and `<LoginPageQuery>` draw or assemble\n * themselves, on top of the screens' own copy (`screens.ts`): the sentence the\n * login page shows for each outcome a Vipps return can report (`?vipps=`).\n */\n\nimport type { BookingLabel } from '../../core/labels';\n\nexport type LoginLabels = {\n /** `?vipps=needs_email_login`: the Vipps login matched more than one profile. */\n 'loginPage.vipps.needsEmailLogin': BookingLabel;\n /** `?vipps=failed`: the Vipps login was cancelled or failed. */\n 'loginPage.vipps.failed': BookingLabel;\n /** The Vipps button where e-mail is the second button: «Fortsett med Vipps». */\n 'login.continueVipps': BookingLabel;\n};\n\nexport const LOGIN_LABELS_NB: LoginLabels = {\n 'loginPage.vipps.needsEmailLogin':\n 'Vi fant mer enn én profil på deg. Logg inn med e-post denne gangen, så kan du koble til Vipps etterpå.',\n 'loginPage.vipps.failed': 'Innloggingen med Vipps ble avbrutt. Prøv igjen, eller bruk e-post.',\n 'login.continueVipps': 'Fortsett med Vipps',\n};\n\nexport const LOGIN_LABELS_EN: LoginLabels = {\n 'loginPage.vipps.needsEmailLogin':\n 'We found more than one profile for you. Log in with e-mail this time, and link Vipps afterwards.',\n 'loginPage.vipps.failed': 'The Vipps login was cancelled. Try again, or use e-mail.',\n 'login.continueVipps': 'Continue with Vipps',\n};\n"],"mappings":";AAiBA,MAAa,kBAA+B;CAC1C,mCACE;CACF,0BAA0B;CAC1B,uBAAuB;AACzB;AAEA,MAAa,kBAA+B;CAC1C,mCACE;CACF,0BAA0B;CAC1B,uBAAuB;AACzB"}
@@ -62,6 +62,7 @@ const SCREEN_LABELS_NB = {
62
62
  "details.email.error": "E-postadressen ser ikke riktig ut.",
63
63
  "details.email.help": "Vi sender bekreftelsen og en kalenderfil hit.",
64
64
  "details.email.label": "E-postadresse",
65
+ "details.email.lockedHelp": "E-posten du logget inn med",
65
66
  "details.error.conflict": "Timen kunne ikke settes opp. Prøv igjen.",
66
67
  "details.error.inProgress": "Bestillingen er allerede mottatt. Se etter bekreftelsen på e-post, eller ring oss.",
67
68
  "details.error.invalidInput": "Noen felt er ikke riktig fylt ut. Se over og prøv igjen.",
@@ -122,6 +123,7 @@ const SCREEN_LABELS_NB = {
122
123
  "login.codeHelp": "Seks sifre, sendt til {email}. Koden varer i ti minutter.",
123
124
  "login.codeHelpVipps": "Seks sifre. Riktig kode kobler Vipps til kontoen din.",
124
125
  "login.codeLabel": "Kode",
126
+ "login.continueEmail": "Fortsett med e-post",
125
127
  "login.cooldownReady": "Nå kan du be om en ny kode.",
126
128
  "login.cooldownWait": "Du kan be om en ny kode om {seconds} sekunder.",
127
129
  "login.emailIntro": "Bruk e-postadressen du bestilte med, så sender vi deg en kode.",
@@ -355,7 +357,33 @@ const SCREEN_LABELS_NB = {
355
357
  "who.guest.children.one": "{count} barn",
356
358
  "who.guest.children.other": "{count} barn",
357
359
  "who.guest.groupLabel": "Hvor mange gjelder timen?",
358
- "who.heading": "Hvem gjelder timen?"
360
+ "who.heading": "Hvem gjelder timen?",
361
+ "recap.label": "Timen du bestiller",
362
+ "recap.time": "Kl. {start}–{end}",
363
+ "recap.line": "{services} hos {stylist}",
364
+ "recap.lineFor": "{who}: {services} hos {stylist}",
365
+ "recap.anyStylist": "første ledige",
366
+ "recap.total": "{price} totalt",
367
+ "recap.edit": "Endre",
368
+ "recap.editLabel": "Endre dag og tid",
369
+ "recap.alsoFree": "Også ledig kl. {time}:",
370
+ "recap.swapTo": "Bestill hos {name} i stedet",
371
+ "time.soonest.heading": "Ledig snart",
372
+ "time.soonest.pick": "{day} kl. {time}",
373
+ "time.soonest.with": "hos {stylist}",
374
+ "time.dayChip.free": "{count} ledige",
375
+ "time.dayChip.freeOne": "{count} ledig",
376
+ "time.dayChip.full": "fullt",
377
+ "summary.hint.who": "Velg hvem som kommer",
378
+ "summary.hint.service": "Trykk på en tjeneste",
379
+ "summary.hint.when": "Trykk på en tid",
380
+ "who.party.children": "Barn",
381
+ "who.party.childrenNote": "",
382
+ "who.party.adult": "Jeg skal også ha time",
383
+ "who.party.adultNote": "Samtidig, ved siden av",
384
+ "who.party.fewer": "Ett barn færre",
385
+ "who.party.more": "Ett barn til",
386
+ "who.party.count": "{count} barn"
359
387
  };
360
388
  const SCREEN_LABELS_EN = {
361
389
  "addChild.birthMonth": "Month of birth (optional)",
@@ -420,6 +448,7 @@ const SCREEN_LABELS_EN = {
420
448
  "details.email.error": "That email address does not look right.",
421
449
  "details.email.help": "We send the confirmation and a calendar file here.",
422
450
  "details.email.label": "Email address",
451
+ "details.email.lockedHelp": "The e-mail you signed in with",
423
452
  "details.error.conflict": "We could not set up the appointment. Please try again.",
424
453
  "details.error.inProgress": "We already have this booking. Look for the confirmation email, or give us a call.",
425
454
  "details.error.invalidInput": "Some fields are not filled in correctly. Please check them.",
@@ -480,6 +509,7 @@ const SCREEN_LABELS_EN = {
480
509
  "login.codeHelp": "Six digits, sent to {email}. The code lasts ten minutes.",
481
510
  "login.codeHelpVipps": "Six digits. The right code links Vipps to your account.",
482
511
  "login.codeLabel": "Code",
512
+ "login.continueEmail": "Continue with e-mail",
483
513
  "login.cooldownReady": "You can ask for a new code now.",
484
514
  "login.cooldownWait": "You can ask for a new code in {seconds} seconds.",
485
515
  "login.emailIntro": "Use the e-mail address you booked with and we will send you a code.",
@@ -713,7 +743,33 @@ const SCREEN_LABELS_EN = {
713
743
  "who.guest.children.one": "{count} child",
714
744
  "who.guest.children.other": "{count} children",
715
745
  "who.guest.groupLabel": "How many people?",
716
- "who.heading": "Who is the appointment for?"
746
+ "who.heading": "Who is the appointment for?",
747
+ "recap.label": "The appointment you are booking",
748
+ "recap.time": "{start}–{end}",
749
+ "recap.line": "{services} with {stylist}",
750
+ "recap.lineFor": "{who}: {services} with {stylist}",
751
+ "recap.anyStylist": "the first available",
752
+ "recap.total": "{price} in total",
753
+ "recap.edit": "Change",
754
+ "recap.editLabel": "Change day and time",
755
+ "recap.alsoFree": "Also free at {time}:",
756
+ "recap.swapTo": "Book with {name} instead",
757
+ "time.soonest.heading": "Free soon",
758
+ "time.soonest.pick": "{day} at {time}",
759
+ "time.soonest.with": "with {stylist}",
760
+ "time.dayChip.free": "{count} free",
761
+ "time.dayChip.freeOne": "{count} free",
762
+ "time.dayChip.full": "full",
763
+ "summary.hint.who": "Choose who is coming",
764
+ "summary.hint.service": "Tap a service",
765
+ "summary.hint.when": "Tap a time",
766
+ "who.party.children": "Children",
767
+ "who.party.childrenNote": "",
768
+ "who.party.adult": "I need an appointment too",
769
+ "who.party.adultNote": "At the same time, side by side",
770
+ "who.party.fewer": "One child fewer",
771
+ "who.party.more": "One more child",
772
+ "who.party.count": "{count} children"
717
773
  };
718
774
  //#endregion
719
775
  export { SCREEN_LABELS_EN, SCREEN_LABELS_NB };